リモート開発メインのソフトウェア開発企業のエンジニアブログです

AWS Lambda上でWebスクレイピング

AWS Lambdaは様々なリソースの制限(例えば900秒を超える処理は実行できない等)があるため、スクレイピング処理をうまく動作させるために一苦労しました。これらの制限をクリアできる処理を動作させることに限定するという条件ではありますが、そこそこ使いやすいスクレイピング環境ができましたのでご紹介します。

デプロイ端末の環境構築

  • Amazon Linux release 2 (Karoo)
  • serverless framework 1.30.3
  • Python 3.6

serverless frameworkについては以下のClassmethodさんの記事が参考になりますので、載せておきます。

https://dev.classmethod.jp/cloud/serverless-framework-lambda-numpy-scipy/

上記の記事にしたがってserverlessのsampleというプロジェクトを作成したという前提で、このプロジェクトにスクレイピングに必要なパッケージ等を追加していきます。この時点でsampleプロジェクト配下は以下のようになっていることを確認します。

sample/
 ├ handler.py
 ├ serverless.yml
 ├ package.json
 ├ package-lock.json
 ├ requirements.txt
 ├ node_modules/
 ├ .serverless/
 ├ .requirements.zip

headless chromeとchrome driverをインストール

headlessのchromeはnpm、pipではインストールできないため、sample配下にbinディレクトリを作成して、その中で手動でダウンロードして展開します。

(venv) $ cd sample
(venv) $ mkdir bin
(venv) $ cd bin

(venv) $ curl -SL https://github.com/adieuadieu/serverless-chrome/releases/download/v1.0.0-37/stable-headless-chromium-amazonlinux-2017-03.zip > headless-chromium.zip
(venv) $ unzip headless-chromium.zip
(venv) $ rm headless-chromium.zip

(venv) $ curl -SL https://chromedriver.storage.googleapis.com/2.37/chromedriver_linux64.zip > chromedriver.zip
(venv) $ unzip chromedriver.zip
(venv) $ rm chromedriver.zip

スクレイピング用のパッケージをインストール

今回はselenium、beautifulSoup4、html5libを使用するので、これらをpipでインストールします。インストールした後は必ずrequirements.txtを更新します。

(venv) $ cd sample
(venv) $ pip install selenium
(venv) $ pip install beautifulSoup4
(venv) $ pip install html5lib

(venv) $ pip freeze > requirements.txt

Pythonのスクレイピング処理を追加

既存のhandler.pyにスクレイピング処理を追加します。selenium、beautifulSoup4はWeb上にサンプルが多数ありますので、細かい部分は省略します。ここでは会員サイトである一覧表を表示し、その一覧をCSVファイルとしてS3にアップする処理を紹介します。

try:
    import unzip_requirements
except ImportError:
    pass
 
import sys
import os
import time
import calendar
import datetime
import logging
import subprocess
import boto3
from pathlib import Path
from bs4 import BeautifulSoup
from selenium import webdriver
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support.ui import Select
 
mode2bucket = {
    'dev': 'sample-scraping-dev-us-east-1',
    'pro': 'sample-scraping'
}
  
def main(event, context):
    print("main start") 

    # log level
    logger = logging.getLogger()
    logger.setLevel(logging.INFO)

    STAGE = os.environ['selected_stage']
    logger.info("stage[%s]" % STAGE)

    MODE = os.environ['scraping_mode']
    logger.info("mode[%s]" % MODE)

    if context:
        logger.info("start lambda_handler() [%s]" % context.function_name)

    #today
    d = datetime.datetime.today()
    #yesterday
    d2 = d - datetime.timedelta(days=1)

    target_url = 'https://target-url.jp/'
    user_id = 'user1234@sample-email.jp'
    password = 'xxxxxxxx'

    try:
        options = webdriver.ChromeOptions()
        options.binary_location = "./bin/headless-chromium"
        options.add_argument("--headless")
        options.add_argument("--disable-gpu")
        options.add_argument("--window-size=1280x1696")
        options.add_argument("--disable-application-cache")
        options.add_argument("--disable-infobars")
        options.add_argument("--no-sandbox")
        options.add_argument("--hide-scrollbars")
        options.add_argument("--enable-logging")
        options.add_argument("--log-level=0")
        options.add_argument("--single-process")
        options.add_argument("--ignore-certificate-errors")
        options.add_argument("--homedir=/tmp")
        driver = webdriver.Chrome(options=options, executable_path='./bin/chromedriver')
        driver.implicitly_wait(30)
        driver.get(target_url)

        # login
        logger.info("before log in")
        driver.find_element_by_name("usr_id").send_keys(user_id)
        driver.find_element_by_name("usr_password").send_keys(password)
        driver.find_element_by_class_name("btnLogin").click()
        logger.info("after log in")
        logger.info(driver.current_url)

        # click smartphone tab
        element_to_hover_over = driver.find_element_by_id("navi02")
        hover = ActionChains(driver).click(element_to_hover_over)
        hover.perform()
        driver.find_element_by_link_text('スマートフォン').click()
        time.sleep(5)
        logger.info(driver.current_url)

        # check mode
        end_date = ""
        file_path = ""
        if MODE == "total":
            #前の月の全日を取得
            end_date = datetime.date(int(d.strftime("%Y")), int(d.strftime("%m")), 1) - datetime.timedelta(days=1)
            file_path = 'sample_site/%s/%s/sample_total_%s.csv' % (d.strftime('%Y'), d.strftime('%m'), d.strftime('%Y%m%d'))
        else:
            end_date = d2
            file_path = 'sample/%s/%s/sample_%s.csv' % (d2.strftime('%Y'), d2.strftime('%m'), d2.strftime('%Y%m%d'))

        # click 'date range' radio button
        driver.find_element_by_id("rep_send_date_radio").click()
        logger.info(driver.current_url)

        #input start date
        start_date = end_date.strftime("%Y/%m/") + str(1).zfill(2)
        logger.info(start_date)
        logger.info(end_date.strftime("%Y/%m/%d"))
        driver.execute_script("document.getElementById('rep_start_date').value = '%s'" % start_date)
        driver.execute_script("document.getElementById('rep_end_date').value = '%s'" % end_date.strftime("%Y/%m/%d"))
        logger.info(driver.current_url)

        # click 'レポート表示' button
        driver.find_element_by_id("rep_button_view").click()
        logger.info(driver.current_url)
        time.sleep(10)

        html = driver.page_source
        soup = BeautifulSoup(html, "html5lib")

        # create text in csv file
        s3_text = ''

        #add header
        thead_tr_list = soup.find('table', id="rep_daily_all_option").findAll('thead')[0].findAll('tr')
        for r1 in range(0, len(thead_tr_list)):
            for elm1 in thead_tr_list[r1].findAll('th'):
                th = elm1.text.strip()
                s3_text += th
                s3_text += '\t'
            s3_text += '\n'
        # add body
        tbody_tr_list = soup.find_all('table', id="rep_daily_all_option")[0].findAll('tbody')[0].findAll('tr')
        for r2 in range(0, len(tbody_tr_list)):
            if (r2 % 100) == 0:
                logger.info("line[%s]" % str(r2))
            for elm2 in tbody_tr_list[r2].findAll('td'):
                td = elm2.text.strip()
                s3_text += td
                s3_text += '\t'
            s3_text += '\n'
        
        driver.quit()

        # put file to s3
        s3 = boto3.resource('s3')
        bucket = mode2bucket[STAGE]
        s3Obj = s3.Object(bucket, file_path)
        s3Obj.put(Body = bytes(s3_text, 'UTF-8'))

        logger.info("finished")
    except Exception as e:
        # キャッチして例外をログに記録
        logger.exception(e)
        post_to_chat("例外が発生しました。", e)
        return 1
    return 0
 
if __name__ == "__main__":
    main('', '')

デプロイの準備

serverless.yml に上記で追加されたbinディレクトリの定義を追加します。(22行目を追加しました。)

service: sample-scraping
 
provider:
  name: aws
  runtime: python3.6
  stage: ${opt:stage, self:custom.defaultStage}

plugins:
  - serverless-python-requirements
 
custom:
  defaultStage: dev
  pythonRequirements:
    dockerizePip: non-linux
#    dockerFile: ./Dockerfile
    slim: true
    zip: true
 
package:
  include:
    - handler.py
    - './bin/**'
  exclude:
    - '**'
 
functions:
  sample-main:
    handler: sample.main
    timeout: 900 # Lambda の最大が 900 秒
    environment:
      selected_stage: ${self:provider.stage}
      scraping_mode: normal

デプロイ・実行

下記のコマンドでデプロイと実行を行います。

(venv) $ cd sample
(venv) $ sls deploy
(venv) $ sls invoke -f sample-main --log

まとめ

serverlessは本当にお手軽に処理のデプロイができますし、Lambdaは使用した分だけの課金ですので、用途に合う要件であれば是非利用してみることをおすすめいたします。

← 前の投稿

Elasticsearch for Apache Hadoopを使ってSparkからAmazon ESにデータと連携してみた

次の投稿 →

(小ネタ)md5sumを使ってリモートにあるファイルとのチェックサムを検証するワンライナー

コメントを残す