サーバーレスでスクレイピングして、狙った中古PCを我が手中に…


どんなことをやるの?

どうしても中古でThinkPadのx250が欲しくなりまして、今回は中古ThinkPad専門店の「Be-Stockさん」のサイトをスクレイピングして、在庫状況を定期的に取得していこうと思います。
今回は初めてサーバーレスで、とは思っておりますが、簡単めに以下のようなアーキテクチャでアプリケーションを作成していこうと思います。

※諸々初めてなので、もし間違いや改善点があれば優しくかつ容赦無くボコボコにしていただきたい。またその際には、私が一年目未満のエンジニアであること、そして極度の飲酒をしながらこの記事を書いていることを念頭に置いて、愛を忘れないように努めていただきたい。

1.サイトの下調べ

スクレイピングの可否

まずはスクレイピングをする前にやることがある。
それはそのサイトをゴニョゴニョして良いのかの確認だ。
ドメイン直下にrobots.txtなるファイルが置かれていることが多いので、これを確認してスクレイピングの可否を確認してみる。
下記がbe-stockさんのrobots.txt。

User-agent: *
Disallow: /redirect.php
Disallow: /shop/redirect.php

User-agent: MJ12bot
Disallow: /

User-agent: AhrefsBot
Disallow: /

User-agent: BLEXBot
Disallow: /

User-agent: Yandex
Disallow: /

User-agent: baiduspider
Disallow: /

User-agent: SemrushBot
Disallow: /

User-agent: istellabot
Disallow: /

User-agent: bingbot
Crawl-Delay: 5



Sitemap: https://www.be-stock.com/web_m2_sitemap1.xml
Sitemap: https://www.be-stock.com/web_m2_sitemap2.xml
Sitemap: https://www.be-stock.com/web_m2_sitemap3.xml

簡単に説明すると、誰が(User-agent)、どこを(Disallow)見てはいけないかである。
ここにAllowが加わるとDisallowのパス下で、許可されてるページを意味する。

これをみる限り、今回は大丈夫そう。。

スクレイピング手順

手順1 : BeStockの「ThinkPad x250のセールページ」をリクエスト

pythonのrequestモジュールでは、引数にクエリを記述することができる。
x250のセールページのクエリは、catなる変数が1876らしい。。もっと分かり易い形式ならいいのに
と思いつつも、私のためのサイトではないのでそこは気にしない。
彼がいうならそうなんだろう。と受け止める。

r = requests.get(
    "https://www.be-stock.com/shop/sale-list.html",
    params=dict(cat=1876)
)

手順2 : セールしているPCの情報をリストで取得(パース)

ページで開発者ツールを使って見てみると、liタグで商品のDOMがまとめられている。
class名はitem product product-itemの三つがあるが、親要素と子要素をみる限り product-itemな気がする。

soup = BeautifulSoup(r.text, 'html.parser')
products_soup = soup.select("li.product-item")
if not products_soup:
    return raise_error('now, x250PC is not in Be-Stock..')

1行目が、
パース(DOMの分析?)ができるようにBeautifulSoupのオブジェクトにする。
この時、requestで取得したデータからタグの部分を第一引数に、またパーサーなるものを第二引数に設定する。(今回は'html.parser'だが、他にも'html5'や'lxml'なるものもあるらしいが、使うには別途インストールが必要な模様)

2行目が、
CSSセレクタを元に、liタグのproduct-itemクラスで一致するDOM全てを配列で取得する。
また、BeautifulSoupの持っているメソッドについては、こちらのチートシートを参照してください。

3行目からが、
セールのPCがない場合も想定されるので、この時に例外を発生させる。
※ここは例外の上げ方が分からなくて、勝手にraise_errorなる関数を定義しました。詳細は後ろのコードまとめをご覧ください。

手順3 : 各PCの情報をパースしてDict型に整形

result = []
for product in products_soup:
    result.append(dict(
        # title: PC名 / stock: 在庫の有無 / info: PCの詳細スペック / link: 詳細ページリンク
        title = product.select_one(".product-item-link").get_text(),
        stock = True if product.select_one(".available") else False,
        info = list(map(lambda detail_info: 
            detail_info.get_text(),
            product.select_one(".itemList").select("dd")
        )),
        link = product.select_one(".product-item-link")["href"]
    ))

infoが若干読みづらくはなってしまったが、サイトのソースをお読みいただければ納得してもらえると信じている。
ddタグが邪魔だったのだ…

これでローカル環境ではバッチリ問題ない!やったね!

2. Lambda

初期設定

適当なディレクトリを用意し、下記コマンドを実行。
pip install モジュール名 -t ./
rm -rf bin
rm -rf *dist-info
これによって、今のディレクトリ下にinstallしたモジュールのファイル群が配置される。
(bin と ~dist-infoは一通りいらないので削除した)
モジュール:requests/ beautifulsoup/ slackclient それぞれについて同じディレクトリ内で実行。

また同じディレクトリに、lambda_function.pyファイルを空で作成する。

以上が完了したら下記のコマンドでzipファイルにする。
zip -r hoge.zip ./*

これをマネジメントコンソールにて、アップロードする。

※参照
Lambdaでの外部モジュールの使い方。
https://qiita.com/SHASE03/items/16fd31d3698f207b42c9

コーディング

先ほど空でアップロードしたlambda_handler.pyファイルに以下の通りコーディングした。

lambda_function.py
import requests
from bs4 import BeautifulSoup
import re
import os
from slack import WebClient

# Slack APIの変数
slack_token = os.getenv("SLACK_TOKEN")
slack_client = WebClient(token=slack_token)
channel_id = "CUAHWS3FU"

def send_message(client, message):
    client.chat_postMessage(
        channel=channel_id,
        text=message
    )

def raise_error(message):
    send_message(slack_client, '[Error]::: ' + message)
    return '[Error]::: ' + message


def lambda_handler(event, context):
    re_message = "ThinkPad X250.*セール"

    # 250のPC情報をリスト取得
    r = requests.get(
        "https://www.be-stock.com/shop/sale-list.html",
        params=dict(cat=1876)
    )
    soup = BeautifulSoup(r.text, 'html.parser')
    products_soup = soup.select("li.product")
    if not products_soup:
        return raise_error('now, x250PC is not in Be-Stock..')

    result = []
    # 商品情報をパース
    for product in products_soup:
        result.append(dict(
            title = product.select_one(".product-item-link").get_text(),
            stock = True if product.select_one(".available") else False,
            info = list(map(lambda detail_info: 
                detail_info.get_text(),
                product.select_one(".itemList").select("dd")
            )),
            link = product.select_one(".product-item-link")["href"]
        ))
    send_message(slack_client, result)
    return "Finished successfully"

ここではSlackAPIを用いて、整形した商品情報をSlackに送っている。

Lambda側での設定

  • SlackのAPIを使うため、tokenを環境変数に用意
  • トリガーとなるCloudwatch Eventsを設定(Designerのトリガーを追加から可能)

以上

→ 次回はこれを、Slackからリクエストしたら情報を取ってきてくれるようにします。