chromoteパッケージでRからInstagramにggplot2を投稿する

The Economist誌がインスタにグラフを投稿する時に気をつけていること、という記事がちょっとバズってたので、

とツイートしたところ、なんかいいねがいっぱいついてしまい、できないと思ってたけど、「それ、Headless Chromeでできんじゃね?」というレスがついてしまったので、

後に引けなくなりやってみました。という話です。

たぶん実際にRStudioからInstagramに投稿したい人はそんなにいないと思うんですけど、chromoteの使い方について書いた記事を今のところ見かけたことがないので、 まあ誰かの役に立つこともあるかなと思って公開します。

(注:これが Instagram利用規約的に OK かどうか自信がないので、試すときはご自身の責任でお願いします。)

RからInstagramに投稿するには

「インスタ PCから」とかで検索するとわかりますが、InstagramはWeb APIがないどころか、基本的にモバイルからしか投稿できません。 基本的に、と書いたのは、ブラウザの開発ツールでモバイル端末に偽装してアクセスする、という抜け道があります。 ということで、RからInstagramに投稿することは可能です。Rからブラウザをそのように操作すれば。

と聞くと、RSeleniumを思い浮かべる人も多いと思いますが、今回はRStudio謹製のchromoteパッケージを使ってみます。

chromoteパッケージとは

一言で言うと、Seleniumを使わないRSeleniumみたいなやつです。SeleniumじゃなくてChrome DevTools Protocolを使っており、どこのご家庭にでもあるChromeさえあればOKというお手軽さがRSeleniumに対する利点です。ただし、まだCRANには上がっておらず開発中なので、この記事に書くことは今後変更されるかもしれません。備えよう。

インストールと準備

インストールはdevtoolsでインストールします。あと、Chromeをまだインストールしていなければインストールしておきましょう。

devtools::install_github("rstudio/chromote")

Linuxを使っている場合はChrome (Chromium)の場所を指定する必要があるかもしれません。私の場合は以下のようにCHROMOTE_CHROMEを設定する必要がありました(たぶんもうすぐ必要なくなるはず)。

Sys.setenv(CHROMOTE_CHROME="/usr/bin/chromium")

セッションの立ち上げ

まずはChromoteSession$new()で新しいセッションを立ち上げましょう。 うまくいっていれば以下のようにb$Browser$getVersion()でバージョン情報が取れるはずです。

library(chromote)

b <- ChromoteSession$new()

b$Browser$getVersion()
#> $protocolVersion
#> [1] "1.3"
#> 
#> $product
#> [1] "HeadlessChrome/80.0.3987.122"
#> 
#> $revision
#> [1] "@cf72c4c4f7db75bc3da689cd76513962d31c7b52"
#> 
#> $userAgent
#> [1] "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/80.0.3987.122 Safari/537.36"
#> 
#> $jsVersion
#> [1] "8.0.426.25"

モバイル端末の偽装

モバイル端末用のふりをするには、スクリーンサイズとかUser Agentをそのモバイル端末と合わせればOKです。そんなに難しいことではないですが、 具体的にどんなスクリーンサイズとかUser Agentを指定すればいいのでしょう。それは、ChromeDevTools/devtools-frontendレポジトリfront_end/emulated_devices/module.jsonにあります。

ということで、これをダウンロードしてきます。

download.file(
  "https://raw.githubusercontent.com/ChromeDevTools/devtools-frontend/master/front_end/emulated_devices/module.json",
  destfile = "data/module.json"
)

library(purrr)

j <- jsonlite::read_json("data/module.json")

# 全部 emulated-device のはずだがいちおう絞り込む
idx <- map_chr(j$extensions, "type") == "emulated-device"
j <- map(j$extensions, "device")[idx]

# 端末名でサブセットできるようにしておく
names(j) <- map_chr(j, "title")

端末は、今回はNexus 5Xを使ってみましょう。別にiPhoneでもなんでも好きなやつを選べば大丈夫だと思います。

device <- j$`Nexus 5X`

まずはUser Agentを設定します。

b$Emulation$setUserAgentOverride(userAgent = device$`user-agent`)

スクリーンサイズも設定します。

orientation <- "vertical" # 横向きにしたければ horizontal

b$Emulation$setDeviceMetricsOverride(
  deviceScaleFactor = device$screen$`device-pixel-ratio`,
  width = device$screen[[orientation]]$width,
  height = device$screen[[orientation]]$height,
  mobile = TRUE
)

さて、これで準備完了です。

ログイン

まずはログイン画面に移動します。

b$Page$navigate("https://www.instagram.com/accounts/login/")

ログインも自動化できるのかもしれませんが、Facebookで認証とかいろんなパターンがありそうだし、 ここは温かみある人手でやった方が怪しまれなさそうなので、

b$view()

でブラウザの画面を表示して自分でログインしましょう。

f:id:yutannihilation:20200301193548p:plain:w300

ログインしたら、Cookieを保存しましょう。 (注:これは漏洩すると超まずい情報なので、うっかりgitにコミットしたりしないように注意しましょう!)

cookies <- b$Network$getCookies()
saveRDS(cookies, "data/cookies.rds")

これで次回からログインを省略できます。 保存してあるCookieを読み込むには以下のようにします。

cookies <- readRDS("data/cookies.rds")
b$Network$setCookies(cookies = cookies$cookies)

# Instagramに移動
b$Page$navigate("https://www.instagram.com/")

投稿

「+」ボタンを押してファイルを選ぶ

投稿するには、まずは第一の難関、画面下部中央の「+」ボタンを押す必要があります。

f:id:yutannihilation:20200301194626p:plain:w300

これはfile chooserなので、モバイル偽装したブラウザからだと押しても何も起こらないんですけど、 押しておかないと次に進めないみたいです(よくわからないけどコンソールにエラーが出る)。

まずはそこまでをかいつまんで説明します。

「+」ボタンにはいい感じのクラスがついていなくて、CSSセレクタだけだと一発でDOM要素を絞り込むことができません。 b$DOM$querySelectorAll()でざっくり取ってきた中から条件を満たすものだけを使う、という感じになります。以降の手順も同じです。 今回はnew-post-buttonという属性値を目印に「+」ボタンを特定しています。(分かりづらいですが、getAttributes()が返すのは「属性値と属性名が交互に入った配列」です)

root <- b$DOM$getDocument()$root$nodeIdの部分は定型句で、毎回やる必要があります(時間が立つとnodeIdは変わっていくっぽい)。

root <- b$DOM$getDocument()$root$nodeId
divs <- b$DOM$querySelectorAll(root, "nav div")
is_plus <- map_lgl(divs$nodeIds, ~ "new-post-button" %in% b$DOM$getAttributes(.)$attributes)

ここで、SeleniumだとDOMを直接クリックできますが、Chrome DevTools Protocolの場合は、

  1. ボタンの位置を調べる
  2. その位置をクリック

という手順を自分で踏む必要があるっぽいです(ここよく理解できてないので間違ってたら教えてください)。

まずは1.、getBoxModel()でボタンの位置を取得します。

plus_button <- b$DOM$getBoxModel(divs$nodeIds[[which(is_plus)]])

ここで、$model$contentにボタンの四隅の位置が入ってるんですが、これはQuadという型で、

An array of quad vertices, x immediately followed by y for each point, points clock-wise. (https://chromedevtools.github.io/devtools-protocol/tot/DOM#type-Quad)

ということで、具体的には

左上x、左上y、右上x、右上y、右下x、右下y、左下x、左下y

に順に値が入っています。ということは、xは「左上x右上xの平均」、yは「左上y左下yの平均」を出せばいいので、それを計算する関数を定義しておきます。

# content is a Quad object:
# "An array of quad vertices, x immediately followed by y for each point, points clock-wise."
calc_center_of_content <- function(content) {
  list(
    x = (content[[1]] + content[[3]]) / 2,
    y = (content[[2]] + content[[8]]) / 2
  )
}

これをクリック、というかタップするにはInput$synthesizeTapGesture()を使います。 このメソッドはまだstableには入っていませんが、最新のChromeを使っていればたぶん使えるはずです。 なければInput$dispatchTouchEvent()というやつを使うのだと思います。

ctr <- calc_center_of_content(plus_button$model$content)
b$Input$synthesizeTapGesture(x = ctr$x, y = ctr$y)

さて、次に隠れているformのfile inputに画像を入力します。 これもDOMを絞り込めないんですが、一番下のやつが投稿用のやつみたいです(3つ目がプロフィール画像っぽい)。 ファイルをセットするのはsetFileInputFiles()でできます。

root <- b$DOM$getDocument()$root$nodeId
file_inputs <- b$DOM$querySelectorAll(root, "form input")

b$DOM$setFileInputFiles(
  list("/path/to/img.jpg"),
  file_inputs$nodeIds[[length(file_inputs$nodeIds)]]
)

さて、これが成功していれば次の画面に進むはずです。

「Next」ボタンを押す

f:id:yutannihilation:20200301202338p:plain:w300

これは先程とほぼ同じです。絞り込み方が、getOuterHTML()でテキストを取得してNextという文字列が含まれているかを見ている点が違います。

root <- b$DOM$getDocument()$root$nodeId
buttons <- b$DOM$querySelectorAll(root, "button")
is_next <- map_lgl(buttons$nodeIds, ~ stringr::str_detect(b$DOM$getOuterHTML(.), "Next"))

button <- b$DOM$getBoxModel(buttons$nodeIds[[which(is_next)]])

ctr <- calc_center_of_content(button$model$content)
b$Input$synthesizeTapGesture(x = ctr$x, y = ctr$y)

文章を入力して「Share」ボタンを押す

f:id:yutannihilation:20200301202417p:plain:w300

ここはステップが2つあります。まず、テキストエリアに文章を入力し、その次に「Share」ボタンを押します。 順を追ってみていきましょう。

まずは文章を入力するために、テキストエリアをタップしてフォーカスを移動させます。

root <- b$DOM$getDocument()$root$nodeId
textareas <- b$DOM$querySelectorAll(root, "textarea")
is_caption <- map_lgl(textareas$nodeIds, ~ "Write a caption…" %in% b$DOM$getAttributes(.)$attributes)
caption <- b$DOM$getBoxModel(textareas$nodeIds[[which(is_caption)]])

ctr <- calc_center_of_content(caption$model$content)
b$Input$synthesizeTapGesture(x = ctr$x, y = ctr$y)

テキストを入力するのにはb$Input$insertText()を使います。これもstableにはないAPIなので、 Chromeのバージョンによっては違うものを使う必要があるかもしれません。

b$Input$insertText("This post is posted from RStudio")

最後に Share を押せば完了です。

root <- b$DOM$getDocument()$root$nodeId
buttons <- b$DOM$querySelectorAll(root, "button")
is_share <- map_lgl(buttons$nodeIds, ~ stringr::str_detect(b$DOM$getOuterHTML(.), "Share"))

button <- b$DOM$getBoxModel(buttons$nodeIds[[which(is_share)]])
ctr <- calc_center_of_content(button$model$content)

b$Input$synthesizeTapGesture(x = ctr$x, y = ctr$y)

感想

軽い気持ちでSeleniumと違うことをやろうと思ってやってみましたが、こんなに大変だと思いませんでした... 実用上は「+」ボタンを押した後は人間が確認しながらやった方がよさそうな気がしてるので、これを今後パッケージ化することがあれば そういう実装(b$view()でブラウザを立ち上げる)にすると思います。

あと、chromoteとRSeleniumの大きな違いとして、chromoteはpromiseとかコールバック関数を扱える、という点があると思いますが、 それはこの記事では触れられませんでした(というか私はあんまり理解できませんでした。。)。 詳しくは README の「Synchronous vs. asynchronous usage」というセクションを読んでください。