ローカルPCでWEBサーバを立ち上げ静的ファイルをSERVEする

最近スクレイピングをすることが多いのですが、開発時に動作確認のために条件に合うWEBサイトを探し出すのが大変なので、ローカルPC上でWEBサーバを立ち上げて静的ファイルをSERVEしたくなることが多いです。
これまでずっと、dockerでnginxを立ち上げてるスタンスだったのですが、会社の同僚がslackで共有していた方法がとてもシンプルでよかったので、そこから触発されていくつかの方法を調べました。 右クリックやCLIで簡単に静的WEBサーバを立ち上げる方法を4つほど紹介しようと思います。

VS Code の Live Server

自分は知らなかったのですが、VS Code拡張機能Live Serverというものがあります。静的および動的ページのライブリロード機能を備えたローカル開発サーバーを起動してくれる拡張機能です。こちらに特徴が列挙してあります。

インストール

VS CodeのExtensionsからLive Serverを検索してインストールします

使い方

一番お手軽な方法は、対象のファイルを右クリックして、Open with Live Serverする方法です。

f:id:rinoguchi:20201019090242p:plain:w400

サーバーが立ち上がり、自動的にブラウザで対象のファイルをOPENしてくれます。

f:id:rinoguchi:20201019090341p:plain:w400

立ち上がったサーバは、デフォルトではworkspace全体を公開します。

設定

特に設定しなくてもいいともいますが、https対応したりしたい場合、.vscode/settings.jsonにて設定を行います。設定できる内容はこちらを参照のこと。

{
    "liveServer.settings.port": 9000,
    "liveServer.settings.root": "/files/"
    "liveServer.settings.https": {
        "enable": true,
        "cert": "/hoge/fuga/server.cert",
        "key": "/hoge/fuga/server.key",
        "passphrase": "xxxxx"
    },
},

Charsetの扱い

UTF-8以外の文字コードのファイルでもContent-Type: text/html; charset=UTF-8のようにcharsetが強制的にUTF-8になってしまう、という問題があります。この点を解消する方法がなさそうです。。

npmパッケージのnode-static

npmモジュールで node-static があります。こちらもコマンド一つでサーバーを起動して静的ファイルをSERVEしてくれる便利なツールです。JavaScriptAPIも提供されています。

インストール

npm install -g node-static

使い方

staticコマンドを実行するだけです。引数なしだとカレントディレクトリ配下を8080ポートでSERVEします。

$ static
serving "." at http://127.0.0.1:8080

オプションは以下の通りです。

$ static -h
USAGE: static [-p <port>] [<directory>]

simple, rfc 2616 compliant file streaming module for node

Options:
  --port, -p          TCP port at which the files will be served                                                          [default: 8080]
  --host-address, -a  the local network interface at which to listen                                                      [default: "127.0.0.1"]
  --cache, -c         "Cache-Control" header setting, defaults to 3600                                                  
  --version, -v       node-static version                                                                               
  --headers, -H       additional headers (in JSON format)                                                               
  --header-file, -f   JSON file of additional headers                                                                   
  --gzip, -z          enable compression (tries to serve file of same name plus '.gz')                                  
  --spa               serve the content as a single page app by redirecting all non-file requests to the index html file
  --indexFile, -i     specify a custom index file when serving up directories                                             [default: "index.html"]
  --help, -h          display this help message      

--headers--header-fileオプションで、Response Headerの値を変更できるのがポイントのような気がします。

Charsetの扱い

UTF-8で保存されたファイルも、EUC-JPで保存されたファイルも、どちらもSERVEした場合、Content-Type: text/htmlとなりました。どうやらタグやブラウザ側で判断させるスタンスのようです。

以下のように、強制的にResponse Headerの値を変更する設定でサーバを起動してみたのですが、残念ながらContent-Typeのあたいは変更されませんでした。

static -p 9000 --headers '{"Content-Type": "text/html; charset=EUC-JP"}'

こちらはISSUEが上がっているのですが、ここ数年ほとんど開発されてないことを考えると対応されることはないでしょう...

python3標準モジュールのhttp.server

python3の http.server を使う手もあります。標準モジュールなので追加インストールは不要です。普段pythonばかり書いてるので、pythonで完結するのは地味に嬉しい気もします。

使い方

以下のような感じです。

python -m http.server 9000

利用可能なオプションは以下の通りです。ほぼnode-staticと同じですね。

$ python -m http.server -h
usage: server.py [-h] [--cgi] [--bind ADDRESS] [--directory DIRECTORY] [port]

positional arguments:
  port                  Specify alternate port [default: 8000]

optional arguments:
  -h, --help            show this help message and exit
  --cgi                 Run as CGI Server
  --bind ADDRESS, -b ADDRESS
                        Specify alternate bind address [default: all interfaces]
  --directory DIRECTORY, -d DIRECTORY
                        Specify alternative directory [default:current directory]

Charsetの扱い

node-staticと全く同じでContent-Typeにcharsetは設定されません。またコマンドラインからだとResponse Headerの値を変更することもできません。

npmパッケージのsuperstatic(おすすめ)

superstaticGoogleのFirebaseサービスの直下のライブラリで、HTML5 pushStateアプリケーション、クリーンURL、キャッシュなどの機能を備えた静的WEBサーバで、CLIAPIが提供されているようです。色々できるみたいなのですが、一番嬉しいのがResponse Headerを設定できることです。

インストール

npm install -g superstatic

使い方

superstaticコマンドを必要な引数を指定して実行するだけです。

superstatic ./files --port 9000 --host 127.0.0.1

わざわざ書くほどのこともないのですが、毎回これを入力するのが手間なので、package.json

{
 "scripts": {
        "serve": "superstatic ./files --port 9000 --host 127.0.0.1"
    }
}

のようにnpmスクリプトの設定をしておいて、

npm run serve

で起動するスタンスにしてあります。

Charsetの扱い

デフォルトだとLive Serverと同じようにContent-Type: text/html; charset=utf-8になるようですが、設定で変更できます!

superstatic.jsonに以下のような感じで設定することで、Content-Typeを実際に上書きされていることを確認しました。

{
    "headers": [
        {
            "source": "euc-jp.html",
            "headers": [
                {
                    "key": "Content-Type",
                    "value": "text/html; charset=EUC-JP"
                }
            ]
        },
        {
            "source": "euc-jp.json",
            "headers": [
                {
                    "key": "Content-Type",
                    "value": "application/json; charset=EUC-JP"
                }
            ]
        },
        // 省略
}

こちらに、headers以外の設定方法も書いてありますので、必要があれば参照してみてください。

さいごに

スクレイピングの動作確認観点だとCharsetを自由にコントロールしたかったので、superstaticを利用することにしました。superstaticは現在もガンガン開発されているようですし、特に理由がなければこれを使うのが良さそうな気がします。

とはいえ、静的ファイルをサクッとSERVEしたいだけなら、正直どれでもOKという感じです。

VuetifyのリセットCSSの影響範囲をVuetify適用範囲内に限定する

Vuetifyは、とても使いやすいVue UIコンポーネントを提供してくれるライブラリなのですが、リセットCSSが強制的にページ全体に適用されてしまうため、既存ページの一部分にVuetifyを使いたくてもその外側のデザインが崩れてしまうことがあります。

ここではその問題を回避する方法について紹介します。

リセットCSSとは

リセットCSSとは各ブラウザがデフォルトで適用するCSSの差異の影響で画面のデザインが変わってしまわない様に、ブラウザが適用するCSSを打ち消すためのものらしいです。

VuetifyのリセットCSS

VuetifyのリセットCSSは以下で、これが強制的に適用されます。

vuetify/_reset.scss at master · vuetifyjs/vuetify · GitHub

例えば、以下の様な結構強烈な設定があります。

button,
input,
select,
textarea {
  background-color: transparent;
  border-style: none;
}

こんなのがあるとVuetifyの適用範囲の外側はもちろん崩れまくりです。
もちろん困っている人も多く github issue も上がっていますが、未だ解決されていません。

対応方法

というわけで、既存ページの一部にVuetifyを適用する場合は、対策を取る必要があります。
具体的には、webpackのLoaderの
sass-loader->css-loader->style-loader
という一連の処理に
sass-loader->postcss-loader->css-loader->style-loader
の様に、postcss全てのCSSセレクタの頭にプロジェクト固有の#idをつけるという処理を追加しました。
例えば以下の様なイメージです。

/* 変更前 */
button,
input, {
  border-style: none;
}

/* 変更後 */
#myapp button,
#myapp input {
  border-style: none;
}

これにより、#myappの外側の要素には一切影響を与えないことが担保されます。

実装

ライブラリインストール

npm install postcss --save-dev
npm install postcss-loader --save-dev
npm install postcss-prefix-selector --save-dev
npm insatll autoprefixer --save-dev

App.vue

Vuetifyの適用範囲は<v-app>で囲む必要がありますが、その親要素にidを設定します。

<template>
  <div id="myapp">
    <v-app>
      <!-- 省略 -->
    </v-app>
  </div>
</template>

webpack.config.js

ポイントを書いておきます。

  • loaderは下から順に適用されるので、以下の順番に処理が行われる
    1. sass-loaderで、sassをcssに変換する
    2. postcss-loaderで、cssをさらに別のcssに変換する
    3. css-loaderで、cssファイルを文字列に変換してJSで扱える様にする
    4. style-loaderで、CSS文字列を<style>としてDOMに挿入する
  • postcss-loader内でpostcss-prefix-selectorを使って、cssセレクタの頭に#myappを追加している
const prefixer = require('postcss-prefix-selector');
// 省略
  module: {
    rules: [
      {
        test: /\.(css|scss|sass)$/,
        use: [
          'vue-style-loader',
          'css-loader',
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {
                plugins: [
                  prefixer({
                    prefix: '#myapp',
                    transform: (prefix, selector, prefixedSelector) => {
                      if (selector.startsWith('html') || selector.startsWith('body')) {
                        return prefix + selector.substring(4);
                      }
                      return prefixedSelector;
                    }
                  })
                ]
              }
            }
          },
          {
            loader: 'sass-loader',
            options: {
              // 省略
            },
          },
        ],
      },
    ]
  }

さいごに

正直、Vuetify本体で対応して欲しいところですが、なかなか対応してくれません。どうやらCDNを利用するケースも合わせて一律の対応するのが難しいっぽいです。

とはいえ、本対応を行うことで、自分が使っている範囲では特に問題はなくなりましたので、まあ良しとしたいと思います。

pythonのGeneratorとAsyncGeneratorの使い方

pythongeneratorについて、yieldするケースはもともとたまに使ってたのですが

  • sendやreturnもできること
  • 非同期処理でも使えること

を知ったので、軽く記事を書いておこうと思います。

ジェネレータ(Generator)

ジェネレータ関数は、を返す代わりにジェネレータ(一連の値を返す特殊なイテレータ)を返す関数で、yieldキーワードを含む関数はジェネレータ関数です。
ジェネレータ関数の中でyieldが呼び出されると、一旦ジェネレータ関数は一時停止して呼び出しの処理が実行されます。次にジェネレータの__next__()が呼び出される(for文やnext(gen)など)と、ジェネレータ関数の処理が再開されます。

Listを返して処理できるケースであれば、ジェネレータ関数にする必要はないと思いますが、

  • 値をyield(生成)した時の状態で何か処理をしたい場合(Listにすると値を生成した際の状態は失われている)
  • Listの件数が多すぎてリソース(メモリ)が足りなくなるので、逐次処理したい場合

などは、ジェネレータ関数を利用したくなります。

yield

一番シンプルなyieldだけを行うサンプルです。このサンプルでは、以下のことを確認できます。

  • ジェネレータ関数の戻り値の型がGenerator[YieldType, SendType, ReturnType] = Generator[int, None, None]になっていること
  • print('started')が1回しか呼び出されないこと
  • print(f'get: {i}')の後に1秒待ってprint(f'yielded: {i}')が実行されること(つまり、ジェネレータ関数が一旦停止していること)
import time
from typing import Generator

def main():
    for i in generate():
        print(f'get: {i}')
        time.sleep(1)

def generate() -> Generator[int, None, None]:
    print('started')
    for i in range(3):
        yield i
        print(f'yielded: {i}')
    print('finished')

if __name__ == "__main__":
    main()

ログ

started 
get: 0
yielded: 0
get: 1
yielded: 1
get: 2
yielded: 2
finished

send

最近知ったのですが、一時停止したジェネレータ関数に対して、send()を使って外から値を渡すことができます。ジェネレータ関数から受け取った値に対して何らかの処理を行なった結果で、ジェネレータ関数の挙動を変えたい時とかに使うんだと思います。このサンプルでは以下のことを確認できます

  • ジェネレータ関数の戻り値の型がGenerator[YieldType, SendType, ReturnType] = Generator[int, int, None]になっていること。(当たり前ですが、yieldとsendで型を合わせる必要はありません)
  • print('started')よりprint('get generator')が先に実行されることから、generator関数を呼び出した時点では、generator関数内部の処理は動いてないこと
  • next(gen)で実行した場合は、r: Optional[str] = (yield i)の値がNoneになっていること
  • gen.send(i * i)で値を渡した場合は、r: Optional[str] = (yield i)の値が渡した値になっていること
  • 処理が完了したらStopIterationがraiseされること(なので、try-exceptが必要)
import time
from typing import Generator, Optional

def main():
    gen: Generator = generate()
    print('get generator')

    try:
        # nextした場合の挙動を確認
        i: int = 0
        for _ in range(3):
            i = next(gen)
            time.sleep(1)
            print(f'get value: {i}')

        # 次にsendした場合の挙動を確認
        for _ in range(3):
            i = gen.send(i * i)
            time.sleep(1)
            print(f'send value: {i * i}')
    except StopIteration:
        print('generator stopped')

def generate() -> Generator[int, int, None]:
    print('started')
    i: int = 1
    while True:
        r: Optional[str] = (yield i)
        print(f'recieved value: {r}')  # 受け取った値を確認
        if r is not None:
            i = i + r  # Noneじゃなければiに受け取った値を加算
        if i > 10:  # 10を超えたらloop終了
            break
    print('finished')

if __name__ == "__main__":
    main()

ログ

get generator
started
get value: 1
recieved value: None
get value: 1
recieved value: None
get value: 1
recieved value: 1
send value: 4
recieved value: 4
send value: 36
recieved value: 36
finished
generator stopped

return

ジェネレータ関数は、値をyieldするだけでなく、returnすることで関数自体の戻り値を返すことができます。ただし、ジェネレータ関数はもともとGeneratorを返却するためreturn valueを呼び出すと、StopIteration(value)をraiseするという動きになります。

このサンプルコードでは以下を確認しています。

  • ジェネレータ関数の戻り値の型がGenerator[YieldType, SendType, ReturnType] = Generator[int, None, str]になっていること
  • return valueの値をexceptでキャッチして値を受け取れること
  • ちなみに、for i in generator()のようにfor文げジェネレータを形だと、StopIteration(value)がraiseされてもfor文自体が正常に終了してしまうため、ジェネレータ関数からreturnされた値を取得できないことに注意
import time
from typing import Generator

def main():
    gen: Generator[int, None, str] = generate()
    while True:
        try:
            i: int = next(gen)
            print(f'get: {i}')
            time.sleep(1)
        except StopIteration as e:
            print(e.value)
            break

def generate() -> Generator[int, None, str]:
    print('started')
    for i in range(3):
        yield i
    print('finished')
    return 'generator finished !!!!!'

if __name__ == "__main__":
    main()

ログ

started
get: 0
get: 1
get: 2
finished
generator finished !!!!!

非同期ジェネレータ(AsyncGenerator)

スクレイピング処理などジェネレータ関数内で非同期処理を使いたいケースもあると思います。調べたところAsyncGeneratorというやつがいて、通常のGeneratorとほぼ同じ使い方で非同期ジェネレータ関数を書くことができました。

yield

単純にyieldするだけのサンプルです。以下を確認しています。

  • for文での呼び出し方
  • ジェネレータ関数の戻り値の型がAsyncGenerator[YieldType, SendType] = Generator[int, None]になっていること(非同期ジェネレータは値を返せないので、ReturnTypeはなし)
import asyncio
from typing import AsyncGenerator

async def main():
    async for i in async_generate():
        print(f'get: {i}')
        await asyncio.sleep(1)

async def async_generate() -> AsyncGenerator[int, None]:
    print('started')
    for i in range(3):
        yield i
        await asyncio.sleep(1)
        print(f'yielded: {i}')
    print('finished')


if __name__ == "__main__":
    asyncio.get_event_loop().run_until_complete(
        main()
    )

ちなみに、async for文のところは以下のように書き換えることもできます。

  • AsyncGeneratorの場合はIterationを終了する際に、StopAsyncIterationをraiseする
  • 次の処理を再開するには__gnext__()を呼び出す
    • この関数を直接呼び出さずに済む方法を探した(next(gen)anext(gen)await next(gen)など)のですが、発見できず
    gen: AsyncGenerator[int, None] = async_generate()
    while True:
        try:
            i: int = await gen.__anext__()
            print(f'get: {i}')
            await asyncio.sleep(1)
        except StopAsyncIteration:
            break

sendについては書き方一緒なので割愛します。

さいごに

ジェネレータは、yieldした時に一旦処理を停止して呼び出し元の処理が完了したら次の処理を行えるところがとても良くて、ジェネレータを覚えてから、for文が深くなっていやだなーと思っていた箇所を、ジェネレータ関数にして階層を少なくして可読性がだいぶ上がったように感じます(多少読み手のリテラシーが求められる部分はありますが)。

とくにヘッドレスブラウザを使ったスクレイピングみたいな、リストとして何かを返すことができないような処理とは相性がよかったです。

かなり便利な機能なので今後も使い倒していこうと思います。

Spark(with Yarn)におけるメモリ設定

DataprocでSparkを利用することがあるのですが、メモリに関する設定でいつもよく分からなくなるので、備忘録的に残しておきます。Resource ManagerにHadoop Yarnを利用しているケースになります。

メモリ設定の包含関係

Sparkに関する設定はこちら、Yarnに関する設定はこちらに書いてあります。

各Executorノードのメモリ設定は以下のような包含関係になっているようです。

マシンメモリ
OS その他 yarn.nodemanager.resource.memory-mb
コンテナに割り当て可能な物理メモリ
spark.executor.memoryOverhead
1ExecutorあたりのVMオーバーヘッド、インターン化文字列、その他Nativeオーバヘッドなど
spark.executor.memory
1Executorが利用する最大メモリサイズ
spark.executor.memory - spark.executor.memory * spark.memory.fraction
Reserved領域。ユーザデータ構造、Sparkの内部メタデータ、予期しない巨大レコードによるOOMからの保護など。
spark.executor.memory * spark.memory.fraction
Sparkアプリケーションが利用する領域。割合で指定
spark.executor.memory * spark.memory.fraction * spark.memory.storageFraction
RDDを格納する領域。割合で指定
アプリ実行用ワーキングメモリ
  • yarn.nodemanager.resource.memory-mb
    • yarn.nodemanager.resource.memory-mb > spark.executor.memoryOverhead + spark.executor.memoryが成り立つ必要あり
    • Dataprocだとマシンメモリサイズの0.8がデフォルト値らしい
  • spark.executor.memoryOverhead
    • 通常spark.executor.memoryの6〜10%程度、最大25%まで設定可能
    • Container killed by YARN for exceeding memory limits. x.x GB of x GB physical memory used. Consider boosting spark.yarn.executor.memoryOverhead or disabling yarn.nodemanager.vmem-check-enabled because of YARN-4714.みたいなエラーが出るときに25%になるように設定したら、エラーが出なくなったことあり
  • spark.executor.memory
    • 1Executorの設定であることに注意
    • x.x GB of x GB physical memory used.みたいなエラーが出るときは、この値を増やす必要あり
  • spark.memory.fraction
    • デフォルトは0.6。ほとんどのワークロードではこれで十分なので普通変更しない。この値が小さいとキャッシュからこぼれ落ちる頻度が高くなり、GCが頻繁に発生するようです
  • spark.memory.storageFraction
    • デフォルトは0.5。ほとんどのワークロードではこれで十分なので普通変更しない。この値を大きくすると、アプリ実行用のワーキングメモリが小さくなるので、スワップアウト・スワップインの頻度が上がるようです

参考サイト

以下のサイトをはじめ色々なサイトを参考にさせていただきました。ありがとうございます。

pythonでスクレイピングする際に利用するライブラリ比較

Pythonスクレイピングを実装する機会があったので、その中で利用した(もしくは技術検証した)ライブラリについて、特徴やどういう時に利用するかについて個人的な見解を書いていこうと思います。

f:id:rinoguchi:20200814012418j:plain:w500

requests

指定したURLに対してリクエストを投げて、レスポンスを取得することができるシンプルなライブラリです。
JavaScript実行を必要としないような静的なサイトからResponseを取得する目的であればこれで十分です。

特徴

  • HTTPレスポンス(ヘッダー、ステータスコード、HTML)を取得できる
  • リクエストヘッダーやクッキーを指定してリクエストすることができる
  • リダイレクト(301や302など)もしてくれる
  • urllib3.util.retry.Retryと一緒に使えばリトライもできる
  • response.textで本文を取得する際、charsetをよしなに解釈してdecodeして文字列にしてくれる
  • できないこと
    • レスポンスを解析して、特定のHtmlElementを取得することはできない => beautifulsoup4やlxmlで使う
    • レスポンスを受け取って、Javascriptが実行された後のHTMLを取得することはできない => requests-htmlやpyppeteerを使う

インストール

pip install requests
# or
poetry add requests

実装サンプル

単純にリクエス

import requests
from requests import Response

response: Response = requests.get('http://quotes.toscrape.com/')
response.status_code
# -> 200
response.headers
# -> {'Server': 'nginx/1.14.0 (Ubuntu)', 'Date': 'Tue, 11 Aug 2020 13:00:00 GMT', 'Content-Type': 'text/html; charset=utf-8', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', 'X-Upstream': 'spidyquotes-master_web', 'Content-Encoding': 'gzip'}
response.text
# -> <!DOCTYPE html><html lang="en"><head>...</head><body></body></html>

リトライありでリクエス

  • リトライ設定の詳細はこちらを参照
  • コネクションエラーなどネットワーク関係のエラーはデフォルトでリトライされる。今回はそれ以外にstatus_forcelist500系ステータスコードが返ったらリトライする設定にしてある
  • raise_on_statusにTrueを設定するとstatus_forcelistで設定したステータスの場合でもエラーをraiseする。FalseにするとResponseが返り、status_codeに500系が設定される
  • Connectionエラーなどの想定されるエラーは、except (RequestException, ConnectionError, Timeout)でまとめてキャッチして握りつぶし、それ以外のRuntimeErrorなどはそのままraiseしている
import requests
from requests import Response, Session
from requests.exceptions import RequestException, Timeout
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from typing import Dict

with Session() as session:
    url: str = 'http://quotes.toscrape.com/'
    headers: Dict[str, str] = {'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36', 'Accept': '*/*'}  # noqa
    retries: Retry = Retry(total=5,  # リトライ回数
                            backoff_factor=3,  # リトライ間隔。例えば2を指定すると 2秒 => 4秒 => 8秒 => 16秒のようになる
                            status_forcelist=[500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511],  # リトライ対象のステータスコード
                            raise_on_status=False  # `status_forcelist`のステータスコードでリトライ終了した場合にエラーraiseするかどうか。FalseだとResponseを返す
                            )

    session.mount(url[0:url.find('//') + 2], HTTPAdapter(max_retries=retries))

    try:
        response: Response = session.get(url, headers=headers)
        response.status_code
        # -> 200
        response.headers
        # -> {'Server': 'nginx/1.14.0 (Ubuntu)', 'Date': 'Tue, 11 Aug 2020 13:00:00 GMT', 'Content-Type': 'text/html; charset=utf-8', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', 'X-Upstream': 'spidyquotes-master_web', 'Content-Encoding': 'gzip'}
        response.text # strとして取得
        # -> <!DOCTYPE html><html lang="en"><head>...</head><body></body></html>
        response.content # bytesとして取得
        # -> b'<!DOCTYPE html><html lang="en"><head>...</head><body></body></html>'

    except (RequestException, ConnectionError, Timeout) as e:
        print(f'possible error occurred. {e}')

Beautiful Soup

Beautiful SoupはHTMLやXMLを解析してデータを抽出するライブラリです。requestsなどで目的のページのHTMLを取得して、そのHTMLを解析する際に利用することが多いと思います。

特徴

  • HTML文字列を解析してエレメントを取得することができる。大きく三つの方法がある
    • タグをたどっていく方法
    • find、find_allなどで対象のタグを探す方法
    • CSSセレクターを使って対象のタグを取得する方法
  • 対象のエレメントの属性を取得することができる
  • HTMLパーサーは、デフォルトだとPython標準ライブラリのパーサーを使うが、HTMLパーサーを変更することもできる
    • lxmlの方が爆速らしいので、大量のデータをパースする必要がある場合はlxmlを使う方が良さそう
    • soup: BeautifulSoup = BeautifulSoup(response.text, 'lxml') のように指定するだけ

インストール

pip install beautifulsoup4
pip install lxml # HTMLパーサーをlxmlに変更する場合

実装サンプル

ソースコード内のコメントを見ればだいたいわかると思いますので、解説は省きますが、これぐらい知っておけば十分な感じがしてます。

import requests
from requests import Response
from bs4 import BeautifulSoup

def main():
    response: Response = requests.get('http://quotes.toscrape.com/')
    soup: BeautifulSoup = BeautifulSoup(response.text)

    # == タグをたどってエレメントを取得する ==
    soup.title
    # -> <title>Quotes to Scrape</title>
    soup.title.parent
    # -> <head><meta charset="utf-8"/><title>Quotes to Scrape</title><link href="/static/bootstrap.min.css" rel="stylesheet"/><link href="/static/main.css" rel="stylesheet"/></head>  # noqa
    soup.body.footer.div.p.a
    # -> <a href="https://www.goodreads.com/quotes">GoodReads.com</a>

    # == find, find_allでタグを探してエレメントを取得する ==
    soup.find("title")
    # -> <title>Quotes to Scrape</title>
    soup.find_all("a")
    # -> [<a href="/" style="text-decoration: none">Quotes to Scrape</a>, <a href="/login">Login</a>, <a href="/author/Albert-Einstein">(about)</a>, ... , <a href="https://scrapinghub.com">Scrapinghub</a>]  # noqa

    # == CSSセレクタでエレメントを取得する ==
    soup.select("body .container .row .quote small.author")
    # -> [<small class="author" itemprop="author">Albert Einstein</small>, <small class="author" itemprop="author">J.K. Rowling</small>, ... , <small class="author" itemprop="author">Steve Martin</small>]  # noqa
    soup.select("body .container .row .quote:first-child small.author")
    # -> [<small class="author" itemprop="author">Albert Einstein</small>]
    soup.select("body .container .row div.quote:last-of-type small.author")
    # -> [<small class="author" itemprop="author">Steve Martin</small>]

    # == エレメントの属性情報を取得する ==
    soup.title.string
    # -> Quotes to Scrape
    soup.title.name
    # -> title
    soup.body.footer.div.p.a["href"]
    # -> https://www.goodreads.com/quotes

    # == 正規表現でテキストを抽出する ==
    import re
    soup.find_all(text=re.compile("^“A "))
    # -> ["“A woman is like a tea bag; you never know how strong it is until it's in hot water.”", '“A day without sunshine is like, you know, night.”'] # noqa

if __name__ == "__main__":
    main()

pyppeteer

pyppeteerは、npmモジュールであるpuppeteerpythonに移植したものです。

ヘッドレスブラウザ(chromium)を開いて、実際にブラウザ内でページを読み込むのでJavaScriptが実行されます。また、CSSセレクタでエレメントを探してクリックしたり、画面遷移を待ったり、指定したJavaScriptコードを実行したりすることが出来ます。

JavaScriptが必要で、エレメントクリックによる画面遷移をしたいケースではこちらを利用するのが良いと思います。

ちなみに、オリジナルのgithubリポジトリは現在Archivedになっており、こちらのリポジトリで開発が継続されているようです。 ほぼ同等のことができるSeleniumもあるのですが、JavaScript世界ではpuppeteerが圧倒的な人気なので、あまり深く考えずpythonでもpyppeteerを採用しました。

特徴

  • ヘッドレスブラウザ上で実際に画面を読み込んで、画面上でボタンクリック=>画面遷移を繰り返すこともできる
  • 画面遷移が完了を、一定時間待つ or 特定の要素の出現を待つ or 指定したJavaScript関数がTrueを返すまで待つ のように複数の方法で待ち合わせることができる
  • セレクタを使って特定のHTMLエレメントを取得することもできる
  • HTTPレスポンス(ヘッダー、ステータスコードなど)を取得できる
  • マルチプロセスで実行もできるし、Linux上でも動かせる
  • マイナス要素
    • chromiumをダウンロードして実行するので、Linux上で動かす時に多少ハマりポイントがある(解決可能)

詳細

長くなったので、別で記事を起こしました。興味があればこちらを参照ください。 rinoguchi.hatenablog.com

requests-html

requests-html、requests・Pyppeteer・PyQuery・BeautifulSoupをラップして一つのAPIとして提供してくれているライブラリです。

なんでもできるようですが、特徴のマイナス要素の所に書いているように、ヘッドレスブラウザを使ってがっつりスクレイピングするようなケースでは正直ものたりないです。その用途ならpyppeteerの方がいいと思います。

requests+Beautiful Soupの代替としてはアリだけど、その用途なら元々難しいところはないし、あえて乗り換える必要はないかなぁという印象でした。

特徴

  • フルJavaScriptサポート
    • requestsで取得したResponseをヘッドレスブラウザで描画して、JavaScriptを実行する
  • CSS Selectorsを使ったエレメント選択
  • コネクションプールおよび永続的なcookieサポート
  • マイナス要素
    • Reponseを取得する際にリトライ機能がない(公式のドキュメントから発見できず)
    • ボタンクリックをしたい時、element.click()みたいな簡単な方法は提供されておらず、ボタンクリックするjavascriptをrenderする必要がある
    • 画面遷移を待つ方法が、時間指定だけ(pyppeteerは、selectorが現れるまでとか、JavaScript関数がtrueを返すまで、とかできる)
    • 画面遷移を伴うボタンクリックを二回実行する方法がない(自分の理解不足かもしれません)
    • ブラウザを表示して実行する方法がないのでデバッグしにくい(自分の理解不足かもしれません)

実装サンプル

ヘッドレスブラウザを使わないケース

ヘッドレスブラウザを使わず以下のケースを実行してみました。

  • Responseオブジェクトを取得する
  • CSSセレクターを使ってエレメント抽出する
  • エレメントの属性情報を取得する
  • ページ内のリンクを全て取得する
from requests_html import HTMLSession
from requests import Response

def main():
    session: HTMLSession = HTMLSession()
    response: Response = session.get('http://quotes.toscrape.com/')

    # == Responseオブジェクトを取得する ==
    response.status_code
    # -> 200
    response.headers
    # -> {'Server': 'nginx/1.14.0 (Ubuntu)', 'Date': 'Tue, 11 Aug 2020 13:11:10 GMT', 'Content-Type': 'text/html; charset=utf-8', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', 'X-Upstream': 'spidyquotes-master_web', 'X-Content-Encoding-Over-Network': 'gzip'}  # noqa
    response.text
    # -> <!DOCTYPE html><html lang="en"><head>...</head><body>...</body></html>

    # == CSSセレクターを使ってエレメント抽出する ==
    # 全て抽出
    response.html.find("body .container .row .quote small.author")
    # -> [<Element 'small' class=('author',) itemprop='author'>, <Element 'small' class=('author',) itemprop='author'>, <Element 'small' class=('author',) itemprop='author'>, ... , <Element 'small' class=('author',) itemprop='author'>]  # noqa
    response.html.find("body .container .row div.quote:first-child small.author")
    # -> [<Element 'small' class=('author',) itemprop='author'>]
    response.html.find("body .container .row div.quote:last-of-type small.author")
    # -> [<Element 'small' class=('author',) itemprop='author'>]

    # 最初の1件抽出
    response.html.find("body .container .row .quote small.author", first=True)
    # -> <Element 'small' class=('author',) itemprop='author'>

    # == エレメントの属性情報を取得する ==
    response.html.find("body .container .row .quote a", first=True).attrs['href']
    # -> /author/Albert-Einstein
    response.html.find("body .container .row .quote small.author", first=True).text
    # -> Albert Einstein

    # == ページ内のリンクを全て取得する ==
    response.html.absolute_links
    # -> {'http://quotes.toscrape.com/author/Jane-Austen', 'http://quotes.toscrape.com/author/Steve-Martin', 'http://quotes.toscrape.com/tag/obvious/page/1/', ... , 'http://quotes.toscrape.com/tag/friendship/'}  # noqa
    response.html.links
    # -> {'/tag/live/page/1/', '/author/Jane-Austen', '/tag/aliteracy/page/1/', '/page/2/', '/tag/choices/page/1/', '/tag/reading/', '/', ... , '/author/Thomas-A-Edison'}  # noqa

if __name__ == "__main__":
    main()

特に難しい点はありませんでしたが、session.get()する際にretryする機能が提供されてなのが少し残念で、Retryしたかったら自前で対応する必要がありそうです。

ヘッドレスブラウザを使うケース

ヘッドレスブラウザを使って、以下を試しました。

  • Response HTMLをヘッドレスブラウザで読み込む
  • CSSセレクタで要素を抽出する
  • JavaScriptを実行して、ボタンをクリックして画面遷移する
  • 画面遷移後のHTMLから要素を抽出する
from requests_html import HTMLSession
from requests import Response

def main():
    session: HTMLSession = HTMLSession()
    response: Response = session.get('https://qiita.com/')

    # == ヘッドレスブラウザで読み込み ==
    # レスポンスHTMLをヘッドレスブラウザで読み込み5秒待つ
    response.html.render(sleep=5)

    # == ヘッドレスブラウザで描画されれたHTMLを取得 ==
    response.html.raw_html
    # -> b'<!DOCTYPE html><html><head><meta charset="utf-8"><title>Qiita</title>...</iframe></div></div></body></html>'

    # == ヘッドレスブラウザで描画されれたHTMLからCSSセレクタで要素を検索 ==
    response.html.find('.p-home_aside div[data-hyperapp-app="TagRanking"] .ra-TagList_content .ra-Tag_name a', first=True)
    # -> <Element 'a' href='/tags/python'>

    # == スクリプトを実行 ==
    # ユーザランキング週間一位をクリックして画面遷移
    response.html.render(script="""
            () => { document.querySelector('div[data-hyperapp-app="UserRanking"] .ra-UserList_content .ra-User_name > a').click() }
        """, sleep=5)

    # 画面遷移後のHTMLから要素(コントリビューションの件数)を抽出
    response.html.find('a[href$="contributions"] > p[class^="UserCounterList__UserCounterItemCount"]', first=True).text
    # -> 60598

if __name__ == "__main__":
    main()

ハマったポイントも共有しておきます。

まず、response.html.render()の際にsleepをする必要があります。
これを設定しないとpyppeteer.errors.NetworkError: Protocol error (Runtime.callFunctionOn): Cannot find context with specified idが発生します。

次に、こちらが致命的なのですが、一回JavaScriptでボタンクリックして画面遷移した後にもう一度JavaScriptで画面遷移しようと思いましたが、うまく動きませんでした。

    response.html.render(script="""
            () => { document.querySelector('a[href$="contributions"]').click() }
        """, sleep=5)

こちらの処理を上記サンプルの最後に追加した場合、本来コントリビューション一覧が表示されることが期待されるのですが、pyppeteer.errors.ElementHandleError: Evaluation failed: TypeError: Cannot read property 'click' of nullというエラー発生しました。どうやら、対象の要素が存在しないことになっているようです。
試しにページのタイトルをJavaScriptで出力してみたところ、画面遷移前のHTMLの値がログに出力されました。どうやら、JavaScriptをrenderするのは最初にrenderしたHTMLに対してのみっぽいのです。(ドキュメントを読んでみたのですがこの部分に関する説明は発見できてませんので、少し怪しいです)。自分の調査では、requests_htmlではヘッドレスブラウザを使って、実際の画面を描画しつつ二回画面遷移を行う方法がなさそうです。

scrapy

Scrapyは、Webサイトをクロールし、ページから構造化データを抽出するために使用されるWebスクレイピングフレームワークです。静的なサイトのクローリングであれば一通りなんでもできるけど、学習コスト高めという印象です。

個人的には、LinkExtractorやSitemapSpiderのように目的にばっちり合致するケースではScrapyを利用するけど、そうじゃなければ、requests+Beautiful Soupやrequests-htmlを利用すると思います。

特徴

  • リクエストを送信して、レスポンスを解析・加工して、アウトプットするまでの一連の処理をまとめて統合的に管理できる
  • Item Pipelineを使えば、あまり考えなくても、WEBリクエスト関係と結果の解析・加工関係を役割分担して実装することができる(ただし実装量は増えるし、Pipeline定義も必要なので初見で何やってるかはわかりにくくなる)
  • LinkExtractor(リンクをたどってURLを列挙)やSitemapSpider(sitemap.xmlrobots.txtからURLを列挙)など、良くある機能をデフォルトで提供してくれている
  • マイナス要素
    • 学習コストは高め。ソースだけ追っても理解できないことが多々ある
    • LinkExtractorやSitemapSpiderは便利だけど、途中までで中断して再開することはできない。数万URLあるようなサイトだと何日も実行にかかるが途中でエラーが発生したら最初からやり直し
    • LinkExtractorやSitemapSpiderはサーバ内の並列化もたぶんうまく動いてない(CONCURRENT_REQUESTSあたりの設定を色々変えても特に早くならない)
    • マルチサーバでの並列化はできない

詳細

長くなったので、記事を起こしました。興味があれば参照ください。

rinoguchi.hatenablog.com

参考リポジトリ

今回検証したソースコードは全てgithubにあげてありますので、必要があればこちらを参照ください。

結局どれを使う?

pythonスクレイピングする際のライブラリの使い分けですが、個人的には

  • JavaScriptの実行が必要 => pyppeteer
  • JavaScriptの実行が不要
    • リンクを辿ってorサイトマップからURL抽出(ただし対象ページ数が1万件ぐらいまで?) => Scrapy
    • それ以外 => requests-html or requests+Beautiful Soup

という感じがいいんじゃないかと思いました。

Scrapyの使い方

Scrapyは、Webサイトをクロールし、ページから構造化データを抽出するために使用されるWebスクレイピングフレームワークです。Scrapyに関してはわかりやすい記事がたくさんあるので、ここでは実装サンプルを紹介しまくるスタンスにしようと思います。

f:id:rinoguchi:20200812181236p:plain

インストール

pip install scrapy
# or
poetry add scrapy

チュートリアルを試す

こちらにしたがって、チュートリアルを試してみます。

scrapy startproject tutorial
or
poetry run scrapy startproject tutorial

を実行するとtutorialフォルダができてその下にテンプレートのソースコード一式が出力されます。

tutorial/spidersフォルダの下に以下の内容でquotes_spider.pyを作ります。

import scrapy
from typing import List

class QuotesSpider(scrapy.Spider):
    name: str = "quotes"

    def start_requests(self):
        urls: List[str] = [
            'http://quotes.toscrape.com/page/1/',
            'http://quotes.toscrape.com/page/2/',
        ]
        for url in urls:
            yield scrapy.Request(url=url, callback=self.parse)

    def parse(self, response):
        page: str = response.url.split("/")[-2]
        filename: str = 'quotes-%s.html' % page
        with open(filename, 'wb') as f:
            f.write(response.body)
        self.log('Saved file %s' % filename)

クローリングを実行します。

scrapy crawl quotes
or
poetry run scrapy crawl quotes

quotes-1.htmlquotes-2.htmlが出力されていれば成功です。

Scrapyの構成要素

何も考えずチュートリアルを実行してみましたが、Scrapyにはいくつかの登場人物がいます。

Spiders

上のチュートリアルでも実装しましたが、どのようにスクレイピングするかを定義する中心的なクラスです。Spiderクラスだけでスクレイピングを完結させることもできます。

Spiderクラスでは、以下の流れで処理が実行されます

  1. start_requests()で最初のRequestオブジェクト(のリスト)を返却します
  2. Requestが実行され、callback関数であるparse()が呼び出される。引数にResponseオブジェクトが渡ってくる
  3. callback関数parse()では、Responseオブジェクトを元に処理を行い(例えばファイルを出力したりとか、DBに記録したりとか)、次のRequestオブジェクト(or リスト)かItemオブジェクトを返却する
  4. Requestオブジェクト(or リスト)が返却された場合は、Requestが実行され、またparse()が呼び出される
  5. Itemオブジェクトが返却された場合は、Pipeline定義にしたがってPipelineが実行される

Item Pipeline

Item Pipelineは、取得したResonseに対してなんらかのアクションを行いたい場合に利用するものです。利用しなくても全然構いません。
一般的な用途は、以下の通りとのことです。

  • HTMLデータのクレンジング
  • スクレイピングされたデータの検証(アイテムに特定のフィールドが含まれていることの確認)
  • 重複チェック(およびそれらのドロップ)
  • 削られたアイテムをデータベースに保存する

Pipelineクラスはprocess_item(self, item, spider)という関数を持つシンプルなクラスです。
process_item()では何らかのアクションを行った後に、Itemオブジェクト or Deferredを返却するか、DropItemエラーをraiseするか、のいずれかの処理を行う必要があります。

個人の見解ですが、Pipelineを利用する場合、Webリクエストに関してはSpiderがその責務を負い、結果のフィルターや加工に関してはPipelineが責務を負う、というような責任分担が重要だと思います。
なので、何らかのアクションをした結果で新しくRequestを投げたくなることがあるかもしれませんが、おそらくPipeline実装すべきではなく、Spiderで実装すべきだと思います。下の方にこれに該当する実装サンプルがあるので見てみてください。

Items

ItemsはPipelineにデータを渡すための入れものです。dictionariesItem objectsdataclass objectsattrs objectsなどいくつかの種類があるそうです。

  • dictionaries: ただのdict
  • Item objects: scrapy.item.Itemを継承したクラスのオブジェクト
  • dataclass objects: dataclassを使って定義したクラスのオブジェクト
  • attrs objects: attr.sを使って定義したクラスのオブジェクト

上のチュートリアルのサンプルコードにもItemは出てきてないのですが、Item Pipelineを使わないのであればItemの定義は特に必要ありません。

実装サンプル

pythonスクリプトから呼び出す

scrapyはコマンド実行するのが基本のようですが、pythonスクリプトから実行することもできます。
チュートリアルコードを少し変更して、指定したURLのHTMLを抽出してファイルに出力するサンプルを書いてみようと思います。

main.pyを作って、その中でMySpiderクラスを作成し、CrawlerProcessでSpiderクラスを実行する処理を書きました。ポイントは以下の通りです。

  • MySpiderのコンストラクタ(__init__())にurlsという引数を追加しており、このurlsインスタンス変数start_urlsに設定することで、リクエスト対象のURLとして設定している
  • parse()の中身はチュートリアルコードと全く同じ
  • main()関数の中でまずスクレイピング設定をDictで定義している。これはテンプレートだとsettings.pyで定義している内容を移植したもの
    • 設定が固定であればsettings.pyをそのまま使ってもOKだが、Dictであれば動的に変更することもできるので、こちらの方が優秀
  • クローリングの実行はCrawlerProcessもしくはCrawlerRunnerで実行できるが今回はCrawlerProcessを使っている(正直使い分けがよくわからない)
  • process.crawl()の第二引数以降がSpiderクラスのコンストラクタに引数として渡される
    • 複数引数をとっても問題ない。またキーワード引数もOK(例:urls=['http://hoge', 'http://fuga']
import scrapy
from scrapy.http import Response
from scrapy.crawler import CrawlerProcess
from typing import List, Dict, Any

class MySpider(scrapy.Spider):
    name = 'my_spider'

    def __init__(self, urls: List[str], *args, **kwargs):
        # Request対象のURLを指定
        self.start_urls = urls
        super(MySpider, self).__init__(*args, **kwargs)

    def parse(self, response: Response):
        page: str = response.url.split("/")[-2]
        filename: str = 'quotes-%s.html' % page
        with open(filename, 'wb') as f:
            f.write(response.body)
        self.log('Saved file %s' % filename)

def main():
    # スクレイピング設定 see: https://docs.scrapy.org/en/latest/topics/settings.html
    settings: Dict[str, Any] = {
        'DOWNLOAD_DELAY': 3,
        'TELNETCONSOLE_ENABLED': False,
    }

    # クローリング実行
    process: CrawlerProcess = CrawlerProcess(settings=settings)
    process.crawl(MySpider, ['http://quotes.toscrape.com/page/1/', 'http://quotes.toscrape.com/page/2/'])
    process.start()  # the script will block here until the crawling is finished

if __name__ == "__main__":
    main()

あとは以下のように実行します。チュートリアルコードと同様にquotes-1.htmlquotes-2.htmlが出力されると思います。

python main.py

このように、簡単にpythonスクリプトからScrapyを実行することができますし、設定もDictで渡せるし、対象URLも引数で渡せるので、動的にスクレイピング設定・スクレイピング対象を変更することも可能です。

個人的にはコマンドライン実行よりこちらの方が柔軟性が高く好きです。なので、これ以降の実装サンプルはCrawlerProcessを使ってSpiderを実行するスタンスで書いていきたいと思います。

Item Pipelineを使う

ResponseオブジェクトのステータスとBODYサイズでフィルターして、OKだったらファイルに出力するというPipelineを作ってみました。

まずは、items.pyを作ってMyItemクラスを定義します。今回はdataclassを使って定義してみました。

from dataclasses import dataclass

@dataclass
class MyItem:
    url: str
    status: int
    title: str
    body: str

次に、pipelines.pyを作って、MyItemクラスを受け取ってフィルターして、ファイルを出力するPipelineを定義します。
Pipelineクラスはprocess_timeという名前の関数が必須で、第二引数にItemオブジェクト、第三引数にSpiderオブジェクトを受け取り、Itemオブジェクト or defer を返却するか、DropItemをraiseする必要があります。

from scrapy.exceptions import DropItem
from scrapy import Spider
from items import MyItem

class StatusFilterPipeline:
    """ステータスでフィルターするパイプライン"""
    def process_item(self, item: MyItem, spider: Spider) -> MyItem:
        if item.status != 200:
            raise DropItem(f'Status is not 200. status: {item.status}')
        return item

class BodyLengthFilterPipeline:
    """BODYサイズでフィルターするパイプライン"""
    def process_item(self, item: MyItem, spider: Spider) -> MyItem:
        if len(item.body) < 11000:
            raise DropItem(f'Body length less than 11000. body_length: {len(item.body)}')
        return item

class OutputFilePipeline:
    """ファイル出力するパイプライン"""
    def process_item(self, item: MyItem, spider: Spider):
        filename: str = f'quotes-{item.url.split("/")[-2]}.html'
        with open(filename, 'wb') as f:
            f.write(item.body)

後は、main.pyを作ってMySpiderクラスを作り、CrawlerProcessで実行します。ポイントは以下です。

  • parse()関数は、ジェネレーター関数になっていて、MyItemオブジェクトをyieldしている
  • settingsITEM_PIPELINESでPipelineの順番を定義している。ここにはPipelineクラスのパスを指定する必要がある。また数字(0-1000)の順にパイプラインは実行される
from scrapy import Spider
from scrapy.http import Response
from scrapy.crawler import CrawlerProcess
from typing import List, Dict, Any, Iterator
from items import MyItem

class MySpider(Spider):
    name = 'my_spider'

    def __init__(self, urls: List[str], *args, **kwargs):
        self.start_urls = urls
        super(MySpider, self).__init__(*args, **kwargs)

    def parse(self, response: Response) -> Iterator[MyItem]:
        yield MyItem(
            url=response.url,
            status=response.status,
            title=response.xpath('//title/text()').extract_first(),
            body=response.body,
        )

def main():
    # スクレイピング設定 see: https://docs.scrapy.org/en/latest/topics/settings.html
    settings: Dict[str, Any] = {
        'DOWNLOAD_DELAY': 3,
        'TELNETCONSOLE_ENABLED': False,
        'ITEM_PIPELINES': {
            'pipelines.StatusFilterPipeline': 100,
            'pipelines.BodyLengthFilterPipeline': 200,
            'pipelines.OutputFilePipeline': 300,
        },
    }

    # クローリング実行
    process: CrawlerProcess = CrawlerProcess(settings=settings)
    process.crawl(MySpider, ['http://quotes.toscrape.com/page/1/', 'http://quotes.toscrape.com/page/2/', 'http://quotes.toscrape.com/page/3/'])
    process.start()  # the script will block here until the crawling is finished

if __name__ == "__main__":
    main()

あとは以下のように実行します。

python main.py

BODYサイズのフィルターの結果、3つのURLのうちquotes-1.htmlquotes-2.htmlの二つのファイルが出力されていれば成功です。
ちなみに、フィルターでDropItemをraiseして除外したItemについては、以下のようにWARNINGログが出力されています。

2020-08-13 19:11:28 [scrapy.core.scraper] WARNING: Dropped: Body length less than 11000. body_length: 10018

Item Pipelineはこんな感じで利用します。
Pipelineによって責務が明確になるのが良さそうですが、理解すべき事項が増えてしまうので、個人的には特別な知識がなくてもコードを見ればわかるように、ただの関数として定義してしまう方が好みですね。

Item Pipelineを使うケースでparse時にItemと次のRequestのyieldを両方実行する

あるページをスクレイピングしてItemを取得しつつ、ページ内のリンクを取得して次のリクエストを行うようなことをやりたいケースはあると思います。そのようなケースではSpiderクラスのparse()の中でItemとRequestの両方をyieldすれば良いはずです。

Item-Pipelineを使うのケースでResponseをparseする際に、NEXTボタンのリンクURLを取得して次のRequestを作りつつ、該当ページのResponse自体はMyItemに詰めてPipelineに流すような感じにしようと思います。

実装は、Item-Pipelineを使うmain.pyparse()に3行追加して、main()process.crawl()する際に渡すURLを1ページ目だけに絞っただけです。

class MySpider(Spider):
    ### (省略) ###

    def parse(self, response: Response) -> Iterator[Union[MyItem, Request]]:
        yield MyItem(
            url=response.url,
            status=response.status,
            title=response.xpath('//title/text()').extract_first(),
            body=response.body,
        )
        if len(response.css('li.next > a')) > 0:  # <= これ以下3行を追加
            next_url: str = response.urljoin(response.css('li.next > a').attrib['href'])
            yield Request(url=next_url, callback=self.parse)

def main():
    ### (省略) ###

    # クローリング実行
    process: CrawlerProcess = CrawlerProcess(settings=settings)
    process.crawl(MySpider, ['http://quotes.toscrape.com/page/1/'])  # <= 開始URLをpage=1だけに変更
    process.start()  # the script will block here until the crawling is finished

実行すると、quotes-1.htmlquotes-2.htmlquotes-8.htmlquotes-9.htmlの4ページ分のHTMLが出力されて、その他のページはDropItemされた旨のログが出ていたので、正常にSpiderのparse()処理で、Itemと次のRequestの両方をyieldすることができていたようです。

LinkExtractorを利用する

ScrapyにはリンクをたどってURLを抽出することに特化した、LinkExtractorクラスが用意されています。これを使って、リンクURLをログに出力するだけのサンプルを書いてみようと思います。

main.pyを作って、MyLinkExtractSpiderクラスを実装し、RuleにLinkExtractorを指定しただけのサンプルを実装しました。

  • LinkExtractorのコンストラクタ引数で色々と指定できます
    • 詳細はこちらを参照
    • 今回は実装してないですが、process_valueに関数を指定できますので、例えばhrefにjavascriptの処理が入っていたとしても関数でパースしてURL部分を抜き出すことも可能です
  • Ruleの引数はこちらを参照ください
from scrapy.http import Response
from scrapy.crawler import CrawlerProcess
from scrapy.spiders import CrawlSpider, Rule
from scrapy.linkextractors import LinkExtractor
from typing import Dict, Any

class MyLinkExtractSpider(CrawlSpider):
    name = 'my_link_extract_spider'
    start_urls = ['http://quotes.toscrape.com/']
    rules = (
        Rule(
            LinkExtractor(
                allow=r'http://quotes.toscrape.com/page/\d+/',  # 許可するURLのパターンを正規表現で
                unique=True,  # URLをユニークにするかどうか
                tags=['a'],  #  対象とするタグ
            ),
            follow=True,  # Responseオブジェクトのリンクをたどるかどうか
            callback='log_url'  # 各リンクに対するResponseを受け取った際に呼び出されるコールバック関数
        ),
    )

    def log_url(self, response: Response):
        print(f'response.url: {response.url}')

def main():
    settings: Dict[str, Any] = {
        'DOWNLOAD_DELAY': 3,
        'TELNETCONSOLE_ENABLED': False,
    }

    process: CrawlerProcess = CrawlerProcess(settings=settings)
    process.crawl(MyLinkExtractSpider)
    process.start()  # the script will block here until the crawling is finished

if __name__ == "__main__":
    main()

これもpython main.pyで実行できます。実行すると以下のように無事リンクURLがログに出力されました。

response.url: http://quotes.toscrape.com/page/1/
response.url: http://quotes.toscrape.com/page/2/
response.url: http://quotes.toscrape.com/page/3/
: (省略)
response.url: http://quotes.toscrape.com/page/10/

SitemapSpiderを利用する

サイトマップファイル(sitemap.xmlrobots.txt)からSitemapSpiderを使ってサイト内のURL一覧を取得することもできます。
こちらにサンプルがあるので、それを参考に、雑ですがQiitaのURL一覧をログに出力するだけのSpiderを作ってみました。

from scrapy.spiders import SitemapSpider

class MySitemapSpider(SitemapSpider):
    name = 'my_sitemap_spider'
    sitemap_urls = ['https://qiita.com/robots.txt']  # sitemap.xmlやrobots.txtを指定する

    def parse(self, response: Response):
        print(f'response.url: {response.url}')

実際に動かしてみると、以下のような感じで大量にURLを抽出することができました。

response.url: https://qiita.com/tags/comprehension
response.url: https://qiita.com/tags/%23rute53
response.url: https://qiita.com/tags/%23dns
response.url: https://qiita.com/tags/%EF%BC%83lightsail
:

サイトマップから抽出するのであれば、相当速いのかと期待してたのですが、全てのURLにRequestを投げるので、普通にLinkExtractorと同じレベルで時間がかかります。また、サイトマップが最新に更新されているという保証もないため、これだけに頼るのは危険そうです。

その他

scrapy shellは便利

scrapy shell 対象URL

のように実行すると、REPLが立ち上がって、コード断片を実行することができるようになります。

たとえば、scrapy shell http://quotes.toscrape.com/を実行した場合は以下のオブジェクトが利用できます。

[s] Available Scrapy objects:
[s]   scrapy     scrapy module (contains scrapy.Request, scrapy.Selector, etc)
[s]   crawler    <scrapy.crawler.Crawler object at 0x102d21610>
[s]   item       {}
[s]   request    <GET http://quotes.toscrape.com/>
[s]   response   <200 http://quotes.toscrape.com/>
[s]   settings   <scrapy.settings.Settings object at 0x102d219a0>
[s]   spider     <DefaultSpider 'default' at 0x1030efb80>
[s] Useful shortcuts:
[s]   fetch(url[, redirect=True]) Fetch URL and update local objects (by default, redirects are followed)
[s]   fetch(req)                  Fetch a scrapy.Request and update local objects 
[s]   shelp()           Shell help (print this help)
[s]   view(response)    View response in a browser

なので、これを見ながら以下のような感じで気軽に試せるのが良い感じです。

>>> response.css('li.next > a')
[<Selector xpath="descendant-or-self::li[@class and contains(concat(' ', normalize-space(@class), ' '), ' next ')]/a" data='<a href="/page/2/">Next <span aria-hi...'>]
>>> response.css('li.next > a').attrib['href']
'/page/2/'

ヘッドレスブラウザ対応

試してないのですが、scrapy-splashを利用すると、ヘッドレスブラウザ上で対象ページを実行することができるようです。 github.com

READMEを軽く読んだだけですが、scrapy.Requestの代わりにSplashRequestをyieldすると、ヘッドレスブラウザsplashで該当ページが読み込まれ、SplashRequestに渡したJavaScriptコードが実行され、その中でreturnされた値がHTTP Responseとして返却されるようです。

JavaScriptコード内では当然ながらdocument.querySelector()などは実行可能なので、特定のエレメントを取得して属性を取得するようなことは可能っぽいですが、画面遷移などに関しては未知数です。

複数の画面をまたいで画面内のエレメントをクリックしながら画面遷移するようなことはおそらくできないので、過度な期待はできないかな、、という印象です。ヘッドレスブラウザが必要であれば自分なら無難にpyppeteerを利用すると思います。

CrawlerProcess(CrawlerRunner)を同一プロセス内で二回実行する

CrawlerProcess(CrawlerRunner)を使ってクローリングする場合、twisted.internet.reactorを使って処理が完了を検知します。しかし、このtwisted.internet.reactorが曲者で、同一プロセス上で二度開始することができません。

以下のようなサンプルプログラムを作って、二回クローリングを実行すると

    process: CrawlerProcess = CrawlerProcess(settings=settings)

    # 1回目実行
    process.crawl(MySpider, ['http://quotes.toscrape.com/page/1/', 'http://quotes.toscrape.com/page/2/'])
    process.start()  # the script will block here until the crawling is finished
    # 2回目実行
    process.crawl(MySpider, ['http://quotes.toscrape.com/page/1/', 'http://quotes.toscrape.com/page/2/'])
    process.start()  # the script will block here until the crawling is finished

以下のようにtwisted.internet.error.ReactorNotRestartableエラーが発生します。

Traceback (most recent call last):
  File "main.py", line 41, in <module>
    main()
  File "main.py", line 37, in main
    process.start()  # the script will block here until the crawling is finished
  File "~/python_scraping_sample/.venv/lib/python3.8/site-packages/scrapy/crawler.py", line 327, in start
    reactor.run(installSignalHandlers=False)  # blocking call
  File "~/python_scraping_sample/.venv/lib/python3.8/site-packages/twisted/internet/base.py", line 1282, in run
    self.startRunning(installSignalHandlers=installSignalHandlers)
  File "~/python_scraping_sample/.venv/lib/python3.8/site-packages/twisted/internet/base.py", line 1262, in startRunning
    ReactorBase.startRunning(self)
  File "~/python_scraping_sample/.venv/lib/python3.8/site-packages/twisted/internet/base.py", line 765, in startRunning
    raise error.ReactorNotRestartable()
twisted.internet.error.ReactorNotRestartable

結論から言うと、これを回避する方法は、別プロセスで実行する方法しかうまくいきませんでした。 (process._stop_reactor()は特に意味がなく、その他色々試しましたが全部ダメでした)

concurrent.futures.ProcessPoolExecutormultiprocessing.Processを使って以下のような感じで実装すると問題なく動きます。

from multiprocessing import Process

def start_crawl(settings: Dict[str, Any], urls: List[str]):
    process: CrawlerProcess = CrawlerProcess(settings=settings)
    process.crawl(MySpider, urls)  #MySpiderの実装は省略
    process.start()  # the script will block here until the crawling is finished

def main():
    settings: Dict[str, Any] = {
        'DOWNLOAD_DELAY': 3,
        'TELNETCONSOLE_ENABLED': False,
    }

    # クローリングを別プロセスで二回実行
    Process(target=start_crawl, args=(settings, ['http://quotes.toscrape.com/page/1/'])).start()
    Process(target=start_crawl, args=(settings, ['http://quotes.toscrape.com/page/2/', 'http://quotes.toscrape.com/page/3/'])).start()

if __name__ == "__main__":
    main()

ちなみに、自分はPySparkのworkerノード上でクローリング処理を実行した際に、この問題に遭遇しました。workerノード上で動くTaskExecutorは同一プロセスで要求されたタスクを順次処理するため、クローリング処理を含むタスクを2回目に実行した際にこのエラーが発生しました。。。

リポジトリ

今回検証したソースコードは全てgithubにあげてありますので、必要があればこちらを参照ください。

さいごに

ドキュメントを見ながら色々と試してみましたが、実装自体は結構簡単でした。
設定だけで振る舞いを変えることができるのも、便利な点と言えると思います。
一方で、ドキュメントを見ずにソースコードだけを見て、どう動くのかを理解するのは難しく学習コストが高いライブラリという印象でした。

個人的には、requests+BeautifulSoupやrequests_htmlのようなコードを見れば分かるライブラリとScrapyのどちらが採用する?聞かれると、正直かなり迷うと思います。たぶん、LinkExtractorやSitemapSpiderのように目的にばっちり合致するケースではScrapyを利用し、それ以外は他のライブラリを選択する気がします。。

pyppeteerの使い方

pyppeteerは、npmモジュールであるpuppeteerpythonに移植したものです。
ヘッドレスブラウザ(chromium)を開いて、実際にブラウザ内でページを読み込んで、CSSセレクタでHTMLエレメントを取得してクリックし、画面遷移したりすることができます。JavaScriptも実行されるので動的に描画された後のHTMLを取得したりすることもできます。また、画面遷移時にCookieも共有されるので、Cookieやセッションが必要なサイトのスクレイピングもできます。

f:id:rinoguchi:20200809004612j:plain

インストール

pip install pyppeteer
# or
poetry add pyppeteer

シンプルな使い方

こちらのサイトにログインするサンプルを書いてみました。ポイントは以下の通りです。

  • pyppeteerに関する処理は全て非同期で実行されるため、忘れずにawaitする必要がある
  • gotoで画面遷移した場合はResponseオブジェクトを受け取れる。ただし、ネットワークエラーなどのResponseが取得できない状況の場合は、エラーがraiseされる
  • ボタンクリックしたい場合は、selectorでエレメントを取得してエレメントをクリックする必要がある
  • クリック後の画面遷移や特定のエレメントの描画を待ち合わせたい場合は、asyncio.gather()を使って複数の非同期処理を待ち合わせる必要がある *asyncio.gather()は複数の非同期処理の戻り値をList形式で受け取れる。順番は引数に指定した順番となる
    • 画面遷移を待ち合わせる場合はpage.waitForNavigation()を、特定のエレメントの描画を待ち合わせたい場合はpage.waitForSelector()を利用する
    • ちなみに、クリック後に画面遷移を待ち合わせずにpage.content()でHTMLを取得しようとするとpyppeteer.errors.NetworkError: Execution context was destroyed, most likely because of a navigation.というエラーが発生する
import asyncio
from pyppeteer.launcher import launch
from pyppeteer.page import Page, Response
from pyppeteer.browser import Browser
from pyppeteer.element_handle import ElementHandle
from typing import List, Any

def main():
    html: str = asyncio.get_event_loop().run_until_complete(extract_html())
    print(html)

async def extract_html() -> str:
    # ブラウザを起動。headless=Falseにすると実際に表示される
    browser: Browser = await launch()
    try:
        page: Page = await browser.newPage()

        # ログイン画面に遷移
        response: Response = await page.goto('http://quotes.toscrape.com/login')
        if response.status != 200:
            raise RuntimeError(f'site is not available. status: {response.status}')

        # Username・Passwordを入力
        await page.type('#username', 'hoge')
        await page.type('#password', 'fuga')
        login_btn: ElementHandle = await page.querySelector('form input.btn')

        # Loginボタンクリック
        results: List[Any] = await asyncio.gather(login_btn.click(), page.waitForNavigation())
        if results[1].status != 200:
            raise RuntimeError(f'site is not available. status: {response.status}')

        # ログイン後の画面のHTML取得
        html: str = await page.content()

        return html
    finally:
        await browser.close()

if __name__ == "__main__":
    main()

マルチプロセスで実行

ページ数の多いサイトをスクレイピングすることになったら、分散処理で一気に処理を実行したくなることもあると思います。先ほどのシンプルなケースをマルチプロセスで一気に動かして実際に動くか試してみました。こちらも問題なく動きました。ポイントは以下です。

  • ProcessPoolExecutorを使ってマルチプロセスで動かしているが、executor.submit()の引数にはコルーチン関数(asyncがついた関数)は使えないので、pallalel_funcという関数を間に挟んでいる
    • コルーチン関数を引数に指定すると、TypeError: cannot pickle 'coroutine' objectというエラーが発生する
import asyncio
from pyppeteer.launcher import launch
from pyppeteer.page import Page, Response
from pyppeteer.browser import Browser
from pyppeteer.element_handle import ElementHandle
from typing import List, Any

from concurrent import futures
from concurrent.futures import Future

def main():
    future_list: List[Future] = []
    htmls: List[str] = []
    with futures.ProcessPoolExecutor(max_workers=2) as executor:
        for i in range(10):
            future_list.append(executor.submit(pallalel_func))

        for future in futures.as_completed(fs=future_list):
            htmls.append(future.result())

    print(f'htmls count: {len(htmls)}')

def pallalel_func() -> str:
    return asyncio.get_event_loop().run_until_complete(extract_html())

async def extract_html() -> str:
    # 「シンプルな使い方」と同一なので省略
 
if __name__ == "__main__":
    main()

Linux上で実行

Dataprocを使ってPySparkクラスタ構成のワーカーノード側で分散して処理を実行しているのですが、Mac OS Xだとうまく動くのですが、LinuxUbuntu 18.04 LTS)だとうまく動かない部分がたくさん出てきて大変でした。大抵はすぐ解決できたのですが、以下の2つは少し時間がかかったので紹介しておきます。
ちなみに、実装自体はMac OS X上で動かしている時と特に変更ありません。

pyppeteer.errors.BrowserError: Browser closed unexpectedly

launch処理でに以下のようなエラーが発生したのですが、まったく原因がログに出力されていません。ライブラリの実装を見るとブラウザのWebSocketのエンドポイントを取得しようとしてタイムアウトが発生しているようです。

  File "/opt/conda/default/lib/python3.7/site-packages/pyppeteer/launcher.py", line 305, in launch
    return await Launcher(options, **kwargs).launch()
  File "/opt/conda/default/lib/python3.7/site-packages/pyppeteer/launcher.py", line 166, in launch
    self.browserWSEndpoint = get_ws_endpoint(self.url)
  File "/opt/conda/default/lib/python3.7/site-packages/pyppeteer/launcher.py", line 225, in get_ws_endpoint
    raise BrowserError('Browser closed unexpectedly:\n')
pyppeteer.errors.BrowserError: Browser closed unexpectedly:

そこで、chromiumを実行するコマンドを直接実行する処理を書いて、実行してみました。

from pyppeteer.launcher import Launcher
import os

cmd: str = " ".join(Launcher().cmd)
print(f'cmd: {cmd}')
os.system(cmd)

すると、以下のようなログが出力されたため、libatk-1.0.so.0が存在しないことが真の原因ということが判明しました。

A 2020-08-07T01:46:22.313971522Z cmd: /home/.local/share/pyppeteer/local-chromium/588429/chrome-linux/chrome --disable-background-networking --disable-background-timer-throttling --disable-breakpad --disable-browser-side-navigation --disable-client-side-phishing-detection --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=site-per-process --disable-hang-monitor --disable-popup-blocking --disable-prompt-on-repost --disable-sync --disable-translate --metrics-recording-only --no-first-run --safebrowsing-disable-auto-update --enable-automation --password-store=basic --use-mock-keychain --headless --hide-scrollbars --mute-audio about:blank --remote-debugging-port=42815 --user-data-dir=/home/.local/share/pyppeteer/.dev_profile/tmp2xd0ki_i 
A 2020-08-07T01:46:22.318604089Z error while loading shared libraries: libatk-1.0.so.0: cannot open shared object file: No such file or directory 

で、結局、以下のようにUbuntuにライブラリを追加してあげることでこの問題は解決できました。

sudo apt-get update
sudo apt-get install -y libatk1.0-0
sudo apt-get install -y libatk-bridge2.0-0
sudo apt-get install -y libgtk-3-0

ssl.SSLError: ("bad handshake: Error([('SSL routines', 'tls_process_server_certificate', 'certificate verify failed')])",)

issueにも上がっていて、urllib3 (1.25). の問題らしいです。

パッチが提供されているので試してみました。pip install pyppdfして、launchをimportする前にパッチを当てましたが、適用方法が間違ってるのかエラーは解決できませんでした。

import pyppdf.patch_pyppeteer
from pyppeteer import launch

仕方ないので、少しセキュリティ的には怖いのですが、urllib3のバージョンを1.24.3に落としてみたところ、うまく動くようになりました。

その他Tips

chromiumコマンドラインオプションを指定する

chromiumコマンドラインオプションは色々あるのですが、以下のようにして指定することができます。ignoreDefaultArgsで一旦デフォルト引数を無視した上で、追加したいオプションを設定する感じです。

browser: Browser = await launch(
    ignoreDefaultArgs=True,
    args=['--disable-gpu', '--user-data-dir=/tmp']
)

aタグのエレメントをclick()しても何故か画面遷移しないことがある

何故かaタグがうまく描画されず、aタグのHtmlElementを取得してclick()しても画面遷移が発生しないことがありました。aタグのHtmlElementは正常に取得できていたためそのケースはelement.click()ではなくpage.goto()で画面遷移してしまうことにしました。以下のような感じです。

link_element: ElementHandle = await page.querySelector('.hoge .fuga a')
href_property: JSHandle = await element.getProperty('href')
href_value: str = str(await href_property.jsonValue()).strip()
if href_value.startswith(('http://', 'https://')):
    await page.goto(href_value)
else:
    await link_element.click()

page.goto()による画面遷移の待ち合わせ

上のサンプルで、element.click()の方の待ち合わせの方法は軽く紹介したのですが、ここではpage.goto()による待ち合わせの方法を紹介します

waitUntilで特定のイベントを待つ

page.goto()waitUntilという引数でいつまで待つかを指定することができます。waitUntilには最大4つのイベントを指定することができます。

  • load: loadイベントが発火するまで
  • domcontentloaded: DOMContentLoadedイベントが発火するまで
  • networkidle0: networkコネクションが0個になって500msec経つまで
  • networkidle2: networkコネクションが2個になって500msec経つまで

自分の場合は安全めに、画像やスクリプトが全部読み込まれてloadイベントが発火し、かつ、0コネクションになって500msec経つという設定で以下のようにしています。

await page.goto('http://quotes.toscrape.com', waitUntil=['load', 'networkidle0'])

page.waitForSelectorで特定のエレメントの出現を待つ

例えばnextリンクが出現するまで待ち合わせたい場合は、以下のようにasyncio.gather()を使って実装します。この戻り値は引数の順番に格納されるため、この場合はindex=0がpage.goto()の結果のResponseになります。

results: List[Any] = await asyncio.gather(
    page.goto('http://quotes.toscrape.com', waitUntil=['load', 'networkidle0']),
    page.waitForSelector('document.querySelector('li.next')')
)
response: Optional[Response] = results[0]

page.waitForFunctionで指定したJavaScriptの関数がTrueを返すまで待つ

JavaScriptの関数がTrueを返すまで待つ方法もあります。waitForSelectorの例と同様に、nextリンクが表示されるのを待ちたい場合は以下のような実装になります。

await asyncio.gather(
    page.goto('http://quotes.toscrape.com', waitUntil=['load', 'networkidle0']),
    page.waitForFunction('() => { return document.querySelector("li.next") != null; }')
)

ファイルダウンロードはうまくいかない

を試してみたのですが、pyppeteerでは現状うまくファイルダウンロードは動かないようです。上記リンク先を開いていただくと、実際に試してみたソースコードも参照できます(動かないので見てもしょうがないですがww)

ファイルダウンロードができない件はissueが上がっていてopenのままです。
Downloading a file (into memory) with pyppeteer · Issue #136 · pyppeteer/pyppeteer · GitHub
pup2.1.1ブランチで対応されているみたいなのですが、残念ながらここ最近pyppeteerは開発が滞っているようなので、これが反映されるのはだいぶ先になりそうな予感です。

ちなみに、通常通りgoto() or element.click()してwaitForNavigation()を待ってResponseを取得した場合、その時点でconnectionが切れてしまっているためresponse.buffer()でコンテンツを取得することができません。
なので、puppeteerに倣って、事前にresponseイベントをインターセプトする設定を行ない、goto() or element.click()をして、responseイベントをインターセプトして、connectionが切れる前にresponse.buffer()してコンテンツを取得する、というような実装内容になっています。実際動かすと
pyppeteer.errors.NetworkError: Protocol error Network.getResponseBody: Target closed.
が発生してしまうので、結局connectionが切れてしまっているようです(涙)

このエラー自体は、こちらにissueが上がっていて、そこに対処方法が書いてあるのですが、websocketsのバージョンを6.0に戻せばいいとか、8.1で上手く動いた、とか書いてあるのですが、実際やってみたところwebsocketsのバージョンを変更しても上手く動きませんでした。

というわけで、ファイルダウンロードは諦めます(URLを取得できるようならそこだけrequestsでファイルダウンロードするようにします)。。

リポジトリ

今回検証したソースコードは全てgithubにあげてありますので、必要があればこちらを参照ください。

さいごに

javascriptの世界では、puppeteerのほうがseleniumより高速だし安定性があるし新しいし圧倒的に人気なので、あまり深く考えずにpyppeteerを利用することにしたのですが、特に今のところは問題はなさそうです。
pythonでasync/awaitで非同期処理を同期的に書くのは初めてだったのですが、ほぼjavascriptと同じノリでかけるのでそこもすぐ慣れました。
pythonでヘッドレスブラウザが必要な状況なら、自分の中ではpyppeteerは第一候補で問題ないと思います。