Quantcast
Channel: Node.jsタグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 9050

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

$
0
0

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-ExecutionPolicyBypass-ScopeProcess-Force;[System.Net.ServicePointManager]::SecurityProtocol=[System.Net.ServicePointManager]::SecurityProtocol-bor3072;iex((New-ObjectSystem.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))# VcXsrv をインストール>chocoinstall-yvcxsrv

インストールしたら 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 install-y libgl1-mesa-dev xorg-dev

# DISPLAY 環境変数を Windows 側 VcXsrv IP にする## シェルログイン時に一度設定されればよいため ~/.profile に設定を記述## << \EOS と書くことで内部テキストを変数展開せずに echo 可能$ sudo tee-a ~/.profile <<\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 起動## 後ろに & をつけないと、GUI アプリケーション終了までコマンドを受け付けなくなる$ gedit &

gedit が起動すれば OK です。

gedit.png

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

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

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

# WSH を使って Windows スタートアップディレクトリに VcxSrv のショートカット作成>$wsh=New-Object-ComObjectWScript.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 -yinstall language-pack-ja

# ロケールを日本語に設定$ sudo update-locale LANG=ja_JP.UTF8

# いったん終了して再起動すればアプリケーションで日本語が使えるようになる$ exit# --- WSL2シェル再起動 ---# タイムゾーンを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 -yinstall fcitx-mozc dbus-x11 x11-xserver-utils
$ dbus-uuidgen | sudo tee /var/lib/dbus/machine-id

# fcitx 設定$ set-o noclobber

# 必要な環境変数等を ~/.profile に追記$ sudo tee-a ~/.profile <<\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

動作確認

gedit を起動して、日本語入力できるか確認してみましょう。

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
constpuppeteer=require('puppeteer')constfs=require('fs')constmain=async()=>{// headless: false => GUIブラウザ起動モードで Puppeteer 起動constbrowser=awaitpuppeteer.launch({headless:false})constpage=awaitbrowser.newPage()// google.com に移動awaitpage.goto('https://www.google.com',{waitUntil:'domcontentloaded'})// スクリーンショット保存fs.writeFileSync('screenshot.png',awaitpage.screenshot({fullPage:true}))// 終了awaitbrowser.close()}main()
# 実行$ node app.js

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

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

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

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

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

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

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

Repository

https://github.com/amenoyoya/scraping-tool/tree/master/google-images

Google画像検索の実行

app.js
constpuppeteer=require('puppeteer')/**
 * puppeteer 実行メイン関数
 * @param {function(puppeteer.Page) => void} callback
 * @param {*} opt 
 */constpuppet=async(callback,opt={})=>{constbrowser=awaitpuppeteer.launch(opt)constpage=awaitbrowser.newPage()awaitpage.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{awaitcallback(page)}catch(err){console.log(err)}if(opt.close!==false){awaitbrowser.close()}}/**
 * Google画像検索実行
 * @param {puppeteer.Page} page
 * @param {string} keyword
 */constsearchGoogleImage=async(page,keyword)=>{awaitpage.goto('https://www.google.co.jp/imghp?hl=ja&tab=wi&authuser=0&ogbl',{waitUntil:'domcontentloaded'})awaitpage.type('input[name="q"]',keyword)// フォーム送信してページ遷移を待つawaitpage.click('button[type="submit"]')awaitpage.waitFor('img.rg_i',{timeout:60000})}/**
 * 動作確認: 「apple」というキーワードで画像検索実行
 */puppet(asyncpage=>{// 画像検索実行: keyword = 'apple'awaitsearchGoogleImage(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
 */constgetGoogleImage=async(page,index)=>{try{// サムネイルをクリック => 自動でスクロールされるため、次のサムネイル画像もLazyLoadされるconstthumbs=awaitpage.$$('img.rg_i')if(thumbs.length<=index){returnfalse}awaitthumbs[index].click()}catch{returnfalse}// サムネイルをクリックして出てくるスライド画像のURLを取得try{awaitpage.waitFor('img[jsname="HiaYvf"]:not([src^="data:image"]):not([src^="https://encrypted-tbn0.gstatic.com"])',{timeout:5000})returnawaitpage.$eval('img[jsname="HiaYvf"]:not([src^="data:image"]):not([src^="https://encrypted-tbn0.gstatic.com"])',el=>el.src)}catch{// スライド画像がサムネイル画像と同じ場合: [前の画像, 今の画像, 次の画像]constimgs=awaitpage.$$('img[jsname="HiaYvf"')if(imgs.length===0){returnfalse}constimg=imgs.length>2?imgs[1]:imgs[0]returnawaitimg.evaluate(el=>el.src)}}

画像のダウンロード

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

# axios インストール$ yarn add axios
app.js
constaxios=require('axios')constfs=require('fs')constpath=require('path')/**
 * 指定URLのリソースをバイナリデータとして取得
 * @param {string} url 
 * @return {Buffer|null}
 */constgetBinaryData=asyncurl=>{try{constres=awaitaxios.get(url,{responseType:'arraybuffer'})returnnewBuffer.from(res.data)}catch(err){console.log(err)returnnull}}/**
 * Base64画像データをバイナリデータとして取得
 * @param {string} base64 
 * @return {Buffer}
 */constdecodeBase64Image=base64=>{returnnewBuffer.from(base64.replace(/^data:\w*\/\w+;base64,/,''),'base64')}/**
 * 指定URLのリソースをファイルにダウンロード
 * @param {string} url 
 * @param {string} filename 
 * @param {boolean} rename 同名ファイルを自動リネームするかどうか
 * @return {boolean}
 */constdownload=async(url,filename,rename=false)=>{constdir=path.dirname(filename)constext=path.extname(filename)// 同名ファイルを自動リネームする場合: filename + '_' + extconstbasename=(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')returntrue}// URLからダウンロードconstbuf=awaitgetBinaryData(url)if(buf===null){returnfalse}fs.writeFileSync(path.join(dir,basename),buf,'binary')returntrue}

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

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

# commander 導入$ yarn add commander

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

app.js
constpuppeteer=require('puppeteer')constaxios=require('axios')constfs=require('fs')constpath=require('path')const{program}=require('commander')/**
 * 指定パスがディレクトリか判定
 * @param {string} target 
 * @return {boolean}
 */constisDirectory=target=>{try{returnfs.statSync(target).isDirectory()}catch(error){returnfalse}}/**
 * 指定パスがファイルか判定
 * @param {string} target 
 * @return {boolean}
 */constisFile=target=>{try{returnfs.statSync(target).isFile()}catch(error){returnfalse}}/**
 * 指定URLのリソースをバイナリデータとして取得
 * @param {string} url 
 * @return {Buffer|null}
 */constgetBinaryData=asyncurl=>{try{constres=awaitaxios.get(url,{responseType:'arraybuffer'})returnnewBuffer.from(res.data)}catch(err){console.log(err)returnnull}}/**
 * Base64画像データをバイナリデータとして取得
 * @param {string} base64 
 * @return {Buffer}
 */constdecodeBase64Image=base64=>{returnnewBuffer.from(base64.replace(/^data:\w*\/\w+;base64,/,''),'base64')}/**
 * 指定URLのリソースをファイルにダウンロード
 * @param {string} url 
 * @param {string} filename 
 * @param {boolean} rename 同名ファイルを自動リネームするかどうか
 * @return {boolean}
 */constdownload=async(url,filename,rename=false)=>{constdir=path.dirname(filename)constext=path.extname(filename)// 同名ファイルを自動リネームする場合: filename + '_' + extconstbasename=(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')returntrue}// URLからダウンロードconstbuf=awaitgetBinaryData(url)if(buf===null){returnfalse}fs.writeFileSync(path.join(dir,basename),buf,'binary')returntrue}/**
 * puppeteer 実行メイン関数
 * @param {function(puppeteer.Page) => void} callback
 * @param {*} opt 
 */constpuppet=async(callback,opt={})=>{constbrowser=awaitpuppeteer.launch(opt)constpage=awaitbrowser.newPage()awaitpage.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{awaitcallback(page)}catch(err){console.log(err)}if(opt.close!==false){awaitbrowser.close()}}/**
 * Google画像検索実行
 * @param {puppeteer.Page} page
 * @param {string} keyword
 */constsearchGoogleImage=async(page,keyword)=>{awaitpage.goto('https://www.google.co.jp/imghp?hl=ja&tab=wi&authuser=0&ogbl',{waitUntil:'domcontentloaded'})awaitpage.type('input[name="q"]',keyword)// フォーム送信してページ遷移を待つawaitpage.click('button[type="submit"]')awaitpage.waitFor('img.rg_i',{timeout:60000})}/**
 * Google画像検索結果ページから画像URL取得
 * @param {puppeteer.Page} page
 * @param {number} index
 * @return {string|boolean} url
 */constgetGoogleImage=async(page,index)=>{try{// サムネイルをクリック => 自動でスクロールされるため、次のサムネイル画像もLazyLoadされるconstthumbs=awaitpage.$$('img.rg_i')if(thumbs.length<=index){returnfalse}awaitthumbs[index].click()}catch{returnfalse}// サムネイルをクリックして出てくるスライド画像のURLを取得try{awaitpage.waitFor('img[jsname="HiaYvf"]:not([src^="data:image"]):not([src^="https://encrypted-tbn0.gstatic.com"])',{timeout:5000})returnawaitpage.$eval('img[jsname="HiaYvf"]:not([src^="data:image"]):not([src^="https://encrypted-tbn0.gstatic.com"])',el=>el.src)}catch{// スライド画像がサムネイル画像と同じ場合: [前の画像, 今の画像, 次の画像]constimgs=awaitpage.$$('img[jsname="HiaYvf"')if(imgs.length===0){returnfalse}constimg=imgs.length>2?imgs[1]:imgs[0]returnawaitimg.evaluate(el=>el.src)}}/**
 * Google画像検索: もっと表示
 * @param {puppeteer.Page} page
 * @return {boolean}
 */constloadMoreGoogleImages=asyncpage=>{try{awaitpage.click('input[jsaction="Pmjnye"]')awaitpage.waitFor(5000)returntrue}catch{returnfalse}}/**
 * 画像URLからファイル名取得
 * @param {string} url
 * @return {string}
 */constgetFilename=url=>{if(url.match(/^data:/)){// base64 データの場合は 'base64.拡張子' というファイル名にするreturn'base64.'+url.match(/^data:image\/([^;]+)/)[1]}constfilename=path.basename(url.match(/[^\?]+/)[0])// クエリ文字列は削除letext=path.extname(filename)letstem=path.basename(filename,ext)// 拡張子抜きのファイル名// 拡張子がない場合は .jpg とするext=ext===''?'.jpg':ext// ファイル名の長さは64文字までとするstem=stem.length>64?stem.slice(0,64):stemreturnstem+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(asyncpage=>{// 保存先ディレクトリ作成if(!isDirectory(program.directory)){if(!fs.mkdirSync(program.directory,{recursive:true})){console.log(`failed to create directory: ${program.directory}`)returnfalse}}// 画像検索実行letmaxdownloads=program.numbersawaitsearchGoogleImage(page,program.keyword)for(leti=0;i<maxdownloads;++i){consturl=awaitgetGoogleImage(page,i)// 画像が取得できない => もっと表示 => もう画像がないなら終了if(!url){if(awaitloadMoreGoogleImages(page)){--i;// もっと表示できたらダウンロード再試行continue;}break;}// ダウンロードconstfilename=path.join(program.directory,getFilename(url))if(true===awaitdownload(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'-ltrue-n 200 -d'./banana_images'

Viewing all articles
Browse latest Browse all 9050

Latest Images

Trending Articles