LoginSignup
12
12

More than 1 year has passed since last update.

WSL2でGUIアプリケーションを使えるようにする(ついでにPuppeteerでGoogle画像検索のスクレイピングに挑戦)

Last updated at Posted at 2020-10-11

WSL2 で GUI アプリケーション実行

VcXsrv を使うことで WSL2 上で Linux GUI アプリケーションを実行することが可能です。

本稿では、GUIアプリケーションとして Google Chrome ブラウザを WSL2 上で実行できるように設定し、Puppeteer で Chrome ブラウザを自動制御してスクレイピングしてみます。

Environment

WSL2 + Ubuntu 20.04 + Docker 開発環境構築 で構築した環境を想定しています。

  • OS: Windows 10 (バージョン 2004, ビルド 19041 以上)
  • WSL2:
    • OS: Ubuntu 20.04
    • Shell: bash

Windows側の設定: VcXsrvの準備

Chocolateyパッケージマネージャ を用いてVcXsrv(Windows用Xサーバ環境)をインストールします。

Win + X |> A => 管理者権限 PowerShell を起動します。

# Chocolatey パッケージマネージャを導入していない場合は導入
> Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))

# VcXsrv をインストール
> choco install -y vcxsrv

インストールしたら Windows スタートメニューから XLaunch を起動します。

  • 起動時のダイアログ設定
    • Select display settings: Multiple Display
    • Select how to start clients: Start no client
    • Extra settings:
      • Clipboard (Primary Selection)
      • Native opengl
      • Disable access control
      • Additional parameters for VcXsrv: -ac

vcxsrv.png

  • ファイアウォールの設定
    • VcXsrv の初回起動時にファイアウォールを聞かれたらパブリックネットワークで許可する
      • ※ プライベートネットワークでは WSL2 と通信できず上手く行かない
  • 初回起動時のファイアウォールの設定に失敗した場合:
    • Win + X |> N => Windows 設定 > 更新とセキュリティ
      • Windowsセキュリティ > ファイアウォールとネットワーク保護 > ファイアウォールによるアプリケーションの許可
        • 「設定の変更」ボタンを押して設定編集する
        • VcXsrv windows xserver の「プライベート」「パブリック」両方にチェックを入れる

vcxsrv_firewall.png

WSL2 (Ubuntu) 側の設定

# Xorg GUI 環境をインストール
## Ubuntu では様々な GUI 環境を利用できるため、好みに応じてインストールすれば良い
$ sudo apt update && sudo apt install -y libgl1-mesa-dev xorg-dev

# DISPLAY 環境変数を Windows 側 VcXsrv IP にする
## << \EOS と書くことで内部テキストを変数展開せずに echo 可能
$ sudo tee -a ~/.bashrc << \EOS
# WSL2 VcXsrv 設定
export DISPLAY=$(cat /etc/resolv.conf | grep nameserver | awk '{print $2}'):0.0
EOS

# シェル再起動
$ exec $SHELL -l

動作確認

動作確認用に gedit (GNOMEデスクトップの標準テキストエディタ) をインストールして起動してみます。

# gedit インストール
$ sudo apt install -y gedit

# gedit 起動
$ gedit

gedit が起動すれば OK です。

gedit.png

起動確認できたら gedit は終了します。

Windows側: VcXsrv のスタートアップ登録

ここまで設定をすると、VcXsrv が起動していないと WSL2 も起動しない状態になってしまいます。
そのため、Windows 起動時に VcXsrv も起動するようにしておきます。

Win + X |> A => 管理者権限 PowerShell を起動します。

# WSH を使って Windows スタートアップディレクトリに VcxSrv のショートカット作成
> $wsh = New-Object -ComObject WScript.Shell
> $shortcut = $wsh.CreateShortcut("$env:USERPROFILE\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\vcxsrv.lnk")

# ショートカット: vcxsrv.exe -multiwindow -ac
> $shortcut.TargetPath = "C:\Program Files\VcXsrv\vcxsrv.exe"
> $shortcut.IconLocation = "C:\Program Files\VcXsrv\vcxsrv.exe"
> $shortcut.Arguments = "-multiwindow -ac"
> $shortcut.Save()

これで、Windows 起動時に VcxSrv が -multiwindow -ac オプションで起動するようになります。

WSL2 (Ubuntu) 側: 日本語フォントを利用できるように設定

今のままでは日本語が表示できないため、日本語フォントを利用できるようにします。

# fc-cache コマンド等をインストール
$ sudo apt install -y fontconfig

# Windows側のフォントをシンボリックリンクすることで日本語フォントを使用できるようにする
$ sudo ln -s /mnt/c/Windows/Fonts /usr/share/fonts/windows

# フォントキャッシュクリア
$ sudo fc-cache -fv

# 日本語言語パックのインストール
$ sudo apt -y install language-pack-ja

# ロケールを日本語に設定
$ echo 'export LANGUAGE=ja_JP.UTF-8' >> ~/.bashrc
$ echo 'export LC_ALL=ja_JP.UTF-8' >> ~/.bashrc
$ exec $SHELL -l
$ sudo update-locale LANG=ja_JP.UTF8

# いったん終了して再起動すればアプリケーションで日本語が使えるようになる
$ exit

# --- WSL2シェル再起動 ---

$ exec $SHELL -l

# タイムゾーンをJSTに設定
$ sudo dpkg-reconfigure tzdata
## TUI で設定: Asia > Tokyo

# 日本語 man (コマンドマニュアル) をインストール
$ sudo apt install -y manpages-ja manpages-ja-dev

WSL2 (Ubuntu) 側: GUI で日本語入力可能にする

ここまでで日本語表示可能になりましたが、まだIMEが使えないため、日本語入力ができません。
そのため、mozc と fcitx を導入します。

  • mozc: Googleが開発しているオープンソースのIME
  • fcitx: Unix系OSにおけるインプットメソッドフレームワーク
# mozc と fcitx を導入
$ sudo apt -y install fcitx-mozc dbus-x11 x11-xserver-utils
$ dbus-uuidgen | sudo tee /var/lib/dbus/machine-id

# fcitx 設定
$ set -o noclobber

# 必要な環境変数等を ~/.bashrc に追記
$ sudo tee -a ~/.bashrc << \EOS
# fcitx 日本語入力設定
export GTK_IM_MODULE=fcitx
export QT_IM_MODULE=fcitx
export XMODIFIERS="@im=fcitx"
export DefaultIMModule=fcitx
if [ $SHLVL = 1 ] ; then
    # 半角全角点滅防止
    xset -r 49 1>/dev/null 2>/dev/null
    # fcitx 起動
    fcitx-autostart 1>/dev/null 2>/dev/null
fi
EOS

# シェル再起動
$ exec $SHELL -l

# 入力設定確認のため fcitx-config-gtk3 を実行
$ fcitx-config-gtk3

2021-01-23 01_10_34-Input Method Configuration.png

入力メソッドとして「Mozc」が設定されているか確認し、「Mozc」が存在しない場合は、下の「+」ボタンから追加します。
(日本語化してからインストールした場合は既に設定されているはずです)

動作確認

gedit を起動して、日本語入力できるか確認してみましょう。
(入力メソッドを Mozc に切り替えるには Ctrl + Space キーを押します)

gedit-ime.png


Google Chrome by Puppeteer on WSL2

ここまでの設定により WSL2 上で GUI アプリケーションを実行できるようになったため、Google Chrome ブラウザをインストールして、Puppeteer から操作できるようにしてみます。
(Chromeブラウザにはヘッドレスモードがあるため、GUIアプリケーションが実行できない環境でもPuppeteerによる操作はできるのですが、今回は挙動を確認しながら操作するためにヘッドレスモードは利用しません)

# google-chrome 用リポジトリ登録
$ echo 'deb http://dl.google.com/linux/chrome/deb/ stable main' | sudo tee /etc/apt/sources.list.d/google-chrome.list
$ wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -

# google-chrome インストール
$ sudo apt update && sudo apt install google-chrome-stable

# google-chrome 起動
$ google-chrome &

# => Google Chrome ブラウザが起動すればOK

chrome.png

Puppeteer でブラウザ操作してみる

  • Puppeteer:
    • Chromeブラウザを Node.js から操作するためのライブラリ
    • ブラウザそのものを操作するため、通常のスクレイピングでは難しい JavaScript の実行などもできる

Node.js のバージョンは安定版であればいくつでも良いと思いますが、ここでは 12.18.2 を利用することにします。

# nodenv で Node.js 12.18.2 を導入
$ nodenv install 12.18.2

# Node.js バージョンを 12.18.2 に切り替え
$ nodenv global 12.18.2

# バージョン確認
$ node -v
v12.18.2

$ yarn -v
1.22.4

# プロジェクトディレクトリを ~/dev/nodejs/puppeteer/ とする
$ mkdir -p ~/dev/nodejs/puppeteer/
$ cd ~/dev/nodejs/puppeteer/

# puppeteer インストール
$ yarn add puppeteer

# app.js 作成
$ touch app.js
$ code app.js
app.js
const puppeteer = require('puppeteer')
const fs = require('fs')

const main = async () => {
  // headless: false => GUIブラウザ起動モードで Puppeteer 起動
  const browser = await puppeteer.launch({
    headless: false
  })
  const page = await browser.newPage()
  // google.com に移動
  await page.goto('https://www.google.com', {waitUntil: 'domcontentloaded'})
  // スクリーンショット保存
  fs.writeFileSync('screenshot.png', await page.screenshot({fullPage: true}))
  // 終了
  await browser.close()
}

main()
# 実行
$ node app.js

# => Chromeブラウザが起動し、Googleホームページのスクリーンショットが撮影される
# => ~/dev/nodejs/puppeteer/screenshot.png に保存される

PuppeteerでGoogle画像検索をスクレイピング

空前の機械学習ブームである昨今、自分でも画像分類の機械学習モデルを作りたいと思うことも少なくありません。
しかしながら、機械学習には大量の学習用データが必要であり、画像分類を行う場合、いかにして画像データを収集するかがネックになります。

一つの手段としては、Googleが提供している検索用のAPIを利用して画像検索・収集する方法がありますが、このAPIは無料利用できるリクエスト数に制限があり、大量のデータ収集には向いておりません。

そのため、スクレイピング(HTTPリクエストを行ってそのレスポンスデータを解析する)という手段をとることが多いです。

本稿においても、Google画像検索を自動的に行い、そのレスポンスデータをスクレイピングして画像収集してみます。

※ スクレイピングは、サーバへの負荷を考え、短時間での大量リクエスト等を行わないよう、マナーを守って行う必要があります。

Repository

Google画像検索の実行

app.js
const puppeteer = require('puppeteer')

/**
 * puppeteer 実行メイン関数
 * @param {function(puppeteer.Page) => void} callback
 * @param {*} opt 
 */
const puppet = async (callback, opt = {}) => {
  const browser = await puppeteer.launch(opt)
  const page = await browser.newPage()
  await page.emulate({
    'name': 'Windows',
    'userAgent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3864.0',
    'viewport': {
      'width': 1024,
      'height': 820,
      'deviceScaleFactor': 1,
      'isMobile': false,
      'hasTouch': false,
      'isLandscape': false
    }
  })
  try {
    await callback(page)
  } catch (err) {
    console.log(err)
  }
  if (opt.close !== false) {
    await browser.close()
  }
}

/**
 * Google画像検索実行
 * @param {puppeteer.Page} page
 * @param {string} keyword
 */
const searchGoogleImage = async (page, keyword) => {
  await page.goto('https://www.google.co.jp/imghp?hl=ja&tab=wi&authuser=0&ogbl', {waitUntil: 'domcontentloaded'})
  await page.type('input[name="q"]', keyword)
  // フォーム送信してページ遷移を待つ
  await page.click('button[type="submit"]')
  await page.waitFor('img.rg_i', {timeout: 60000})
}

/**
 * 動作確認: 「apple」というキーワードで画像検索実行
 */
puppet(async page => {
  // 画像検索実行: keyword = 'apple'
  await searchGoogleImage(page, 'apple')
}, {
  headless: false, // 挙動確認できるようにヘッドレスモードは利用しない
  slowMode: 500, // 挙動確認しやすいように+サーバ負荷を考えて、一つのアクションに 500 ミリ秒のインターバル
  close: false, // Chromeブラウザの開発ツールでレスポンスデータの解析を行いたいため、ブラウザ終了させない
})
# 実行
$ node app.js

実行すると、Chromeブラウザが立ち上がり、「apple」というキーワードでGoogle画像検索が実行されるはずです。

F12 キーで、Chromeの開発ツールが表示されるので、それを見ながらHTMLソースコードを解析 → ダウンロードするべき画像データを特定します。

search-apple.png

検索結果ページから画像URLを抽出

2020年10月現在のGoogle画像検索は LazyLoad で画像を表示する仕様のため、画像URLの抽出には工夫が必要です。

今回は以下のように、「サムネイル画像をクリック」して出てくる「スライド画像URL」を抽出することで対応しています。

app.js
/**
 * Google画像検索結果ページから画像URL取得
 * @param {puppeteer.Page} page
 * @param {number} index
 * @return {string|boolean} url
 */
const getGoogleImage = async (page, index) => {
  try {
    // サムネイルをクリック => 自動でスクロールされるため、次のサムネイル画像もLazyLoadされる
    const thumbs = await page.$$('img.rg_i')
    if (thumbs.length <= index) {
      return false
    }
    await thumbs[index].click()
  } catch {
    return false
  }
  // サムネイルをクリックして出てくるスライド画像のURLを取得
  try {
    await page.waitFor(
      'img[jsname="HiaYvf"]:not([src^="data:image"]):not([src^="https://encrypted-tbn0.gstatic.com"])',
      {timeout: 5000}
    )
    return await page.$eval(
      'img[jsname="HiaYvf"]:not([src^="data:image"]):not([src^="https://encrypted-tbn0.gstatic.com"])',
      el => el.src
    )
  } catch {
    // スライド画像がサムネイル画像と同じ場合: [前の画像, 今の画像, 次の画像]
    const imgs = await page.$$('img[jsname="HiaYvf"')
    if (imgs.length === 0) {
      return false
    }
    const img = imgs.length > 2? imgs[1]: imgs[0]
    return await img.evaluate(el => el.src)
  }
}

画像のダウンロード

画像URLを特定できればダウンロードは簡単です。
axios を使うのが楽です。

# axios インストール
$ yarn add axios
app.js
const axios = require('axios')
const fs = require('fs')
const path = require('path')

/**
 * 指定URLのリソースをバイナリデータとして取得
 * @param {string} url 
 * @return {Buffer|null}
 */
const getBinaryData = async url => {
  try {
    const res = await axios.get(url, {responseType: 'arraybuffer'})
    return new Buffer.from(res.data)
  } catch (err) {
    console.log(err)
    return null
  }
}

/**
 * Base64画像データをバイナリデータとして取得
 * @param {string} base64 
 * @return {Buffer}
 */
const decodeBase64Image = base64 => {
  return new Buffer.from(base64.replace(/^data:\w*\/\w+;base64,/, ''), 'base64')
}

/**
 * 指定URLのリソースをファイルにダウンロード
 * @param {string} url 
 * @param {string} filename 
 * @param {boolean} rename 同名ファイルを自動リネームするかどうか
 * @return {boolean}
 */
const download = async (url, filename, rename = false) => {
  const dir = path.dirname(filename)
  const ext = path.extname(filename)
  // 同名ファイルを自動リネームする場合: filename + '_' + ext
  const basename = (rename && isFile(filename))? path.basename(filename, ext) + '_' + ext: path.basename(filename)
  // base64デコード
  if (url.match(/^data:/)) {
    fs.writeFileSync(path.join(dir, basename), decodeBase64Image(url), 'binary')
    return true
  }
  // URLからダウンロード
  const buf = await getBinaryData(url)
  if (buf === null) {
    return false
  }
  fs.writeFileSync(path.join(dir, basename), buf, 'binary')
  return true
}

CLIツールとして完成させる

最後に commander を導入してCLIツールとして完成させます。

# commander 導入
$ yarn add commander

以下、全コードを記載します。

app.js
const puppeteer = require('puppeteer')
const axios = require('axios')
const fs = require('fs')
const path = require('path')
const {program} = require('commander')

/**
 * 指定パスがディレクトリか判定
 * @param {string} target 
 * @return {boolean}
 */
const isDirectory = target => {
  try {
    return fs.statSync(target).isDirectory()
  } catch (error) {
    return false
  }
}

/**
 * 指定パスがファイルか判定
 * @param {string} target 
 * @return {boolean}
 */
const isFile = target => {
  try {
    return fs.statSync(target).isFile()
  } catch (error) {
    return false
  }
}

/**
 * 指定URLのリソースをバイナリデータとして取得
 * @param {string} url 
 * @return {Buffer|null}
 */
const getBinaryData = async url => {
  try {
    const res = await axios.get(url, {responseType: 'arraybuffer'})
    return new Buffer.from(res.data)
  } catch (err) {
    console.log(err)
    return null
  }
}

/**
 * Base64画像データをバイナリデータとして取得
 * @param {string} base64 
 * @return {Buffer}
 */
const decodeBase64Image = base64 => {
  return new Buffer.from(base64.replace(/^data:\w*\/\w+;base64,/, ''), 'base64')
}

/**
 * 指定URLのリソースをファイルにダウンロード
 * @param {string} url 
 * @param {string} filename 
 * @param {boolean} rename 同名ファイルを自動リネームするかどうか
 * @return {boolean}
 */
const download = async (url, filename, rename = false) => {
  const dir = path.dirname(filename)
  const ext = path.extname(filename)
  // 同名ファイルを自動リネームする場合: filename + '_' + ext
  const basename = (rename && isFile(filename))? path.basename(filename, ext) + '_' + ext: path.basename(filename)
  // base64デコード
  if (url.match(/^data:/)) {
    fs.writeFileSync(path.join(dir, basename), decodeBase64Image(url), 'binary')
    return true
  }
  // URLからダウンロード
  const buf = await getBinaryData(url)
  if (buf === null) {
    return false
  }
  fs.writeFileSync(path.join(dir, basename), buf, 'binary')
  return true
}

/**
 * puppeteer 実行メイン関数
 * @param {function(puppeteer.Page) => void} callback
 * @param {*} opt 
 */
const puppet = async (callback, opt = {}) => {
  const browser = await puppeteer.launch(opt)
  const page = await browser.newPage()
  await page.emulate({
    'name': 'Windows',
    'userAgent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3864.0',
    'viewport': {
      'width': 1024,
      'height': 820,
      'deviceScaleFactor': 1,
      'isMobile': false,
      'hasTouch': false,
      'isLandscape': false
    }
  })
  try {
    await callback(page)
  } catch (err) {
    console.log(err)
  }
  if (opt.close !== false) {
    await browser.close()
  }
}

/**
 * Google画像検索実行
 * @param {puppeteer.Page} page
 * @param {string} keyword
 */
const searchGoogleImage = async (page, keyword) => {
  await page.goto('https://www.google.co.jp/imghp?hl=ja&tab=wi&authuser=0&ogbl', {waitUntil: 'domcontentloaded'})
  await page.type('input[name="q"]', keyword)
  // フォーム送信してページ遷移を待つ
  await page.click('button[type="submit"]')
  await page.waitFor('img.rg_i', {timeout: 60000})
}

/**
 * Google画像検索結果ページから画像URL取得
 * @param {puppeteer.Page} page
 * @param {number} index
 * @return {string|boolean} url
 */
const getGoogleImage = async (page, index) => {
  try {
    // サムネイルをクリック => 自動でスクロールされるため、次のサムネイル画像もLazyLoadされる
    const thumbs = await page.$$('img.rg_i')
    if (thumbs.length <= index) {
      return false
    }
    await thumbs[index].click()
  } catch {
    return false
  }
  // サムネイルをクリックして出てくるスライド画像のURLを取得
  try {
    await page.waitFor(
      'img[jsname="HiaYvf"]:not([src^="data:image"]):not([src^="https://encrypted-tbn0.gstatic.com"])',
      {timeout: 5000}
    )
    return await page.$eval(
      'img[jsname="HiaYvf"]:not([src^="data:image"]):not([src^="https://encrypted-tbn0.gstatic.com"])',
      el => el.src
    )
  } catch {
    // スライド画像がサムネイル画像と同じ場合: [前の画像, 今の画像, 次の画像]
    const imgs = await page.$$('img[jsname="HiaYvf"')
    if (imgs.length === 0) {
      return false
    }
    const img = imgs.length > 2? imgs[1]: imgs[0]
    return await img.evaluate(el => el.src)
  }
}

/**
 * Google画像検索: もっと表示
 * @param {puppeteer.Page} page
 * @return {boolean}
 */
const loadMoreGoogleImages = async page => {
  try {
    await page.click('input[jsaction="Pmjnye"]')
    await page.waitFor(5000)
    return true
  } catch {
    return false
  }
}

/**
 * 画像URLからファイル名取得
 * @param {string} url
 * @return {string}
 */
const getFilename = url => {
  if (url.match(/^data:/)) {
    // base64 データの場合は 'base64.拡張子' というファイル名にする
    return 'base64.' + url.match(/^data:image\/([^;]+)/)[1]
  }
  const filename = path.basename(url.match(/[^\?]+/)[0]) // クエリ文字列は削除
  let ext = path.extname(filename)
  let stem = path.basename(filename, ext) // 拡張子抜きのファイル名
  // 拡張子がない場合は .jpg とする
  ext = ext === ''? '.jpg': ext
  // ファイル名の長さは64文字までとする
  stem = stem.length > 64? stem.slice(0, 64): stem
  return stem + ext
}

/**
 * CLIオプションパース
 */
program
  .option('-d, --directory <string>', '保存先ディレクトリ', '.')
  .option('-l, --headless <boolean>', 'ヘッドレスモード', false)
  .option('-s, --slowmode <number>', '動作遅延[ms]', 500)
  .option('-C, --noclose <boolean>', 'ブラウザを自動で閉じない', false)
  .option('-n --numbers <number>', 'ダウンロード数', 100)
  .option('-r --rename <boolean>', '同名ファイルを自動リネーム', false)
  .requiredOption('-k, --keyword <string>', '検索キーワード')
  .parse(process.argv)

/**
 * メインプログラム
 */
puppet(async page => {
  // 保存先ディレクトリ作成
  if (!isDirectory(program.directory)) {
    if (!fs.mkdirSync(program.directory, {recursive: true})) {
      console.log(`failed to create directory: ${program.directory}`)
      return false
    }
  }
  // 画像検索実行
  let maxdownloads = program.numbers
  await searchGoogleImage(page, program.keyword)
  for (let i = 0; i < maxdownloads; ++i) {
    const url = await getGoogleImage(page, i)
    // 画像が取得できない => もっと表示 => もう画像がないなら終了
    if (!url) {
      if (await loadMoreGoogleImages(page)) {
        --i; // もっと表示できたらダウンロード再試行
        continue;
      }
      break;
    }
    // ダウンロード
    const filename = path.join(program.directory, getFilename(url))
    if (true === await download(url, filename, program.rename)) {
      console.log(`downloaded: ${filename}`)
    } else {
      // ダウンロードできなかった場合は maxdownloads を一つ増やす
      ++maxdownloads
    }
  }
}, {
  headless: program.headless,
  slowMode: program.slowmode,
  close: program.noclose? false: true,
})

使い方

# Help
$ node app.js -h

# Usage
$ node app.js -k <キーワード> [options]
Options:
  -d, --directory <string>  保存先ディレクトリ (default: ".")
  -l, --headless <boolean>  ヘッドレスモード (default: false)
  -s, --slowmode <number>   動作遅延[ms] (default: 500)
  -C, --noclose <boolean>   ブラウザを自動で閉じない (default: false)
  -n --numbers <number>     ダウンロード数 (default: 100)
  -r --rename <boolean>     同名ファイルを自動リネーム (default: false)

# Example
## 「banana」という検索キーワードで画像を200枚 ./banana_images/ に保存
$ node app.js -k 'banana' -l true -n 200 -d './banana_images'
12
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
12