Python + pipenv 環境に Selenium + ChromeDriver + BeautifulSoup4 でクローリング・スクレイピングしてみる

前回紹介した pipenv を使って、Python おなじみのクローリング・スクレイピングを行ってみる。

今回作成したプロジェクトの全量は以下の GitHub リポジトリに上げたのでドウゾ。

目次

pipenv の準備

各プロジェクトディレクトリの配下の .venv/ ディレクトリ配下に仮想環境のファイルを配置するようにするため、環境変数で以下のように設定しておく。

export PIPENV_VENV_IN_PROJECT=1

Windows の場合も PIPENV_VENV_IN_PROJECT というキーに true などの値を設定して環境変数を設定しておくこと。

プロジェクトの作成

プロジェクトの作成手順はこんな感じ。

# Pipfile を生成する
$ pipenv --python 3.7

# クローリング用の Selenium をインストールする
$ pipenv install selenium

# OS にインストールしてあるChrome のバージョンに合わせてインストールする
$ pipenv install chromedriver-binary~=77.0

# HTML のパース・スクレイピングを行う BeautifulSoup4 をインストールする
$ pipenv install beautifulsoup4

chromedriver-binary というのは、ChromeDriver のパスを自動判定してくれるモノ。後述するコードがシンプルになり、環境依存が減らせる期待があるので入れてみた次第。

クローリングを行うスクリプト

まずは指定の URL にアクセスして HTML ソースを取得する、いわゆる「クローリング」を行うスクリプトを作ってみる。

この辺については、Node.js で似たようなことをやったことがあるので、Node.js 民はそちらも参考にしていただけると理解がしやすいかと。

from pathlib import Path
import platform

import chromedriver_binary

from selenium import webdriver

def main():
  prepareHtmlDirectory()
  driver = createDriver()
  pageSource = getPageSource(driver, 'http://google.com/')
  writePageSource(pageSource, 'google.html')
  driver.quit()

# Chrome WebDriver を生成する
def createDriver():
  chromeOptions = webdriver.ChromeOptions()
  chromeOptions.add_argument('--headless')
  chromeOptions.add_argument('--disable-gpu')
  chromeOptions.add_argument('--no-sandbox')
  driver = webdriver.Chrome(options = chromeOptions)
  return driver

def prepareHtmlDirectory():
  htmlDir = Path(Path.cwd()).joinpath('html')
  if not htmlDir.exists():
    htmlDir.mkdir()

def getPageSource(driver, url):
  driver.get(url)
  print(f'{url} : {driver.title}')
  return driver.page_source

def writePageSource(pageSource, fileName):
  file = Path(Path.cwd()).joinpath('html').joinpath(fileName)
  file.write_text(pageSource, encoding = 'utf-8', newline = '')

if __name__ == '__main__':
  main()

chromedriver_binaryimport すると、webdriver.Chrome() で ChromeDriver をセットアップする時に executable_path 引数の指定が要らなくなるのが特徴。

しかし試したところ、MacOS ではこの chromedriver-binary で Chrome を上手く認識できなかったのと、ヘッドレスモードで起動できなかったので、MacOS で動かす場合は以下のように修正した。

# MacOS では executable_path で指定しないと上手く読み込めなかったのでコメントアウトしておく
# import chromedriver_binary

# Chrome WebDriver を生成する
def createDriver():
  # MacOS では ChromeDriver のパスを指定しておく
  executablePath = '/usr/local/bin/chromedriver'
  
  chromeOptions = webdriver.ChromeOptions()
  
  # MacOS では Headless モードにすると上手く起動しなかったので避ける
  if platform.system() != 'Darwin':
    chromeOptions.add_argument('--headless')
  
  chromeOptions.add_argument('--disable-gpu')
  chromeOptions.add_argument('--no-sandbox')
  
  driver = webdriver.Chrome(executable_path = executablePath, options = chromeOptions)
  return driver

ChromeDriver 周りは、実行環境によってどうしてもムリが出てくるので、OS をまたいだクロスブラウザテストをする、みたいな要件がないのであれば、Docker とかに寄せて OS 環境から統一させた方が良いと思う。


あとは pathlib という組み込みモジュールを使って、Node.js でいうところの fs みたいなことを色々やった。特にファイル書き込み write_text() などは、改行コードやエンコードが未指定の時は OS に依存して勝手に Shift-JIS とか CRLF にされたりするので、しっかり明示しておくと環境差異が出なくなる。

あとファイル末尾に if __name__ == '__main__': と書いているのは、Python でよくあるイディオム。このファイルを別のファイルが import した時に、勝手に main() 関数が実行されないようにするやり方。Node.js で再現するとしたら if(!module.parent) とかで判定すればいいかな。

このスクリプトを実行する時は以下のようにする。

$ pipenv run python example_crawl.py

./html/google.html ファイルが生成されて、中身が書かれていれば OK。

スクレイピングを行うスクリプト

続いて、HTML ソースから必要な情報を抜き取る、「スクレイピング」処理を行うスクリプトを作る。

import json
from pathlib import Path

from bs4 import BeautifulSoup

def main():
  prepareJsonDirectory()
  html = getHtml('google.html')
  soup = parseHtml(html)
  scrapedDict = scrape(soup)
  writeJson(scrapedDict, 'google.json')
  print('End')

def prepareJsonDirectory():
  jsonDir = Path(Path.cwd()).joinpath('json')
  if not jsonDir.exists():
    jsonDir.mkdir()

def getHtml(fileName):
  file = Path(Path.cwd()).joinpath('html').joinpath(fileName)
  html = file.read_text(encoding = 'utf-8')
  return html

# BeautifulSoup でパースする
def parseHtml(html):
  soup = BeautifulSoup(html, 'html.parser')
  return soup

# スクレイピングして結果を Dict で返す
def scrape(soup):
  # 辞書 (Dict) を用意する
  scrapedDict = {}
  
  # 一つ要素を取得する
  print('my_text')
  scrapedDict['my_text'] = soup.select('a.gb_e')[0].string
  print('  ' + scrapedDict['my_text'])
  
  # p 要素を全て取得してリストに詰める
  print('a_elements')
  elements = soup.find_all('a')
  elementList = []
  
  for index in range(len(elements)):
    element = elements[index]
    paragraphString = element.string
    print(f'  [{index}] : {paragraphString}')
    elementList.append(paragraphString)
  
  # 辞書に格納する
  scrapedDict['a_elements'] = elementList
  
  return scrapedDict

def writeJson(scrapedDict, fileName):
  jsonFile = Path(Path.cwd()).joinpath('json').joinpath(fileName)
  with jsonFile.open('w', encoding = 'utf-8') as file:
    # Unicode 出力しないようにする
    json.dump(scrapedDict, file, indent = 2, ensure_ascii = False)

if __name__ == '__main__':
  main()

先程のコードで取得した ./html/google.html が存在する前提でコードを書いている。

HTML ファイルを読み込み、BeautifulSoup でパースし、jQuery 的にセレクタを使って要素を特定できる形式にする。

Node.js では、Cheerio という npm パッケージが BeautifulSoup と似たようなことをしてくれるので、HTML パーサなんだなーと思えていれば OK。

そして scrape() 関数の中で、実際に要素を特定してテキストを取得、Dict (辞書・JS でいう連想配列) に詰めている。

この Dict という Python の型は、組み込みモジュール json を使うとすぐに JSON 形式に変換できるので、そのまま ./json/google.json というファイルで書き出している。

このスクリプトの実行方法はこんな感じ。

$ pipenv run python example_scrape.py

./json/google.json ができていれば OK。

以上

まだまだ Python 素人なので、Python らしくない書き方が散見されるかもしれないが、とりあえず Python を使ってクローリングしたい、スクレイピングしたい、というところはコレで始められるかなと思う。

参考文献