投稿者「python-tksssk」のアーカイブ

pipenvのinstall, update, syncの違い

pipenvのライブラリインストール系のコマンドの意味がわからなくなるのでまとめました。

install update sync
意味 Pipfileからインストール Pipfileに従ってアップデート Pipfile.lockでインストール
Pipfile 更新あり
Pipfile.lock 更新あり 更新あり

新規に環境構築 pipenv install

Pipfileの依存関係ルールに従ってPipfileの全ライブラリをインストールする。
各ライブラリの依存関係がチェックされて、ルールにマッチするようにライブラリはアップデートされ、Pipfile.lockは更新される。
すでにライブラリがインストール済みのとき、ルールにマッチしていれば、アップデートがあっても最新に更新されることはない。
例えば、ルール上は >=2.0.0 で2.0.0がインストール済みのとき、2.0.1が公開されていてもアップデートはされない。新規にインストールするときは2.0.1が選択される。これをルールに合う最新にアップデートするときは、pipenv updateを使う。
依存関係のチェックが走るので遅い。

ライブラリの追加 pipenv install “hoge==2.0.0”

Pipfileの依存関係ルールを hoge=”==2.0.0″に更新後、そのルールに従って hoge ライブラリがインストールされる。
ルールを更新するのでPipfileが更新される、ルールに従ってライブラリが更新されるのでPipfile.lockも更新される。
依存関係のチェックが走るので遅い。

ライブラリの更新 pipenv update

Pipfileの依存関係ルールに従ってPipfileの全ライブラリの更新をチェックしてルールに合う形で最新版にアップデートする。ルールは変更していないのでPipfileは更新されない。Pipfile.lockは更新される。
依存関係のチェックが走るので遅い。

環境の再現 pipenv sync

Pipfile.lockに記載されたとおりにライブラリをインストールする。ルールは更新されないのでPipfileは更新されない。Pipfile.lockに合わせてインストールされるのでPipfile.lockも更新されない。
依存関係のチェックが走らないので早い。

pipenv install --deploy

Pipfile.lockを更新せずに、Pipfileからのインストールを試みる。Pipfile.lockがPipfileより古い場合にはエラーになる。--skip-lockよりは安全に、更新チェックをしつつインストールできる。

lockせずにインストール pipenv install --skip-lock

Pipfile.lockを更新せずにインストールを行う。Pipfileのルールに従ってインストールが行われるがPipfile.lockは更新されないだけで、Pipfile.lockと同じとおりにインストールされるわけではない。Pipfile.lockを完全無視するのでpipenv syncのほうが良い。
例:ルール上は >=2.0.0 でPipfile.lockが2.0.0。2.0.1が公開されていて、ローカルには未インストールの場合、インストールされるのはルールにマッチする最新の2.0.1だが、Pipfile.lockは更新されないため、環境の再現にはならない。

datetime の UTC / JSTの変換についてまとめ

datetime.datetimeのオブジェクトをUTCやJSTに変換するさいのパターンを網羅してみます。

datetime型には、timezone情報がついているかどうかの区別がある。timezone情報があるもの=aware。ないもの=native。

タイムゾーンの準備

timezoneを扱う場合はdateutilを使用するのが今日では一般的のよう。

  • メジャーな日付操作ライブラリとしてはdateutilかpytzがある
  • 標準ライブラリのtimezoneにtimedeltaで生成するのも+-9時間のようなシンプルなものなら便利
  • pytzは厳密すぎて+09:19のようなハマりがあるので避けたほうが良い
  • Python3.9以降限定ならば標準ライブラリのzoneinfoを使うのも良い

ひとまずここでは、Python3.9以前のことも考慮してdateutilを使用します。

pip install python-dateutil
from datetime import datetime
from dateutil import tz
JST = tz.gettz('Asia/Tokyo')
UTC = tz.gettz("UTC")

各種パターンのdatetimeを生成する

2021/04/01 11:22:33(UTC)を例に生成するパターン

タイムゾーンあり(aware) タイムゾーンなし (native)
UTC A) 2021/04/01 11:22:33 UTC B) 2021/04/01 11:22:33
JST C) 2021/04/01 20:22:33 JST D) 2021/04/01 20:22:33

A) awareでUTCのdatetime

UTCのタイムゾーンをもつawareなdatetimeを作成するには、tzinfoにタイムゾーンを渡す。

>>> A = datetime(2021, 4, 1, 11, 22, 33, tzinfo=UTC)
>>> A.isoformat()
'2021-04-01T11:22:33+00:00'

※(pytzを使う場合)ちなみにtzinfoを渡す方法をpytzで行うと、問題が発生するのでpytzのときにはこの方法は使わず、localizeメソッドを使うこと。

>>> datetime(2021, 4, 1, 11, 22, 33, tzinfo=pytz.timezone("Asia/Tokyo")) # これは間違い
datetime.datetime(2021, 4, 1, 11, 22, 33, tzinfo=<DstTzInfo 'Asia/Tokyo' LMT+9:19:00 STD>)
                                                                              ^^
>>> pytz.timezone("Asia/Tokyo").localize(datetime(2021, 4, 1, 11, 22, 33))
datetime.datetime(2021, 4, 1, 11, 22, 33, tzinfo=<DstTzInfo 'Asia/Tokyo' JST+9:00:00 STD>)

B) nativeなdatetime (UTC)

普通にdatetimeのインスタンスをタイムゾーン情報なしに作成する。
タイムゾーンは持たないので、それがUTCの時刻かどうかは明示できない。

>>> B = datetime(2021, 4, 1, 11, 22, 33)
>>> B.isoformat()
'2021-04-01T11:22:33'

C) awareでJSTのdatetime

Aと同様。

>>> C = datetime(2021, 4, 1, 20, 22, 33, tzinfo=JST)
>>> C.isoformat()
'2021-04-01T11:22:33+09:00'

D) nativeなdatetime (日本時間)

設定する時刻が違うだけで、B)と同様。

>>> D = datetime(2021, 4, 1, 20, 22, 33)
>>> D
datetime.datetime(2021, 4, 1, 20, 22, 33)

タイムゾーンの変更 A→C / C→A

UTCタイムゾーンのAをJSTタイムゾーンに変換するときはと astimezoneメソッドを使用。
これはCに一致する。UTCにおける11:22:33から+9時間されて20:22:33になる。逆も同様。

>>> A.astimezone(JST)
datetime.datetime(2021, 4, 1, 20, 22, 33, tzinfo=<DstTzInfo 'Asia/Tokyo' JST+9:00:00 STD>)
>>> A.astimezone(JST) == C
True
>>> C.astimezone(UTC)
datetime.datetime(2021, 4, 1, 11, 22, 33, tzinfo=<UTC>)
>>> C.astimezone(UTC) == A
True

タイムゾーンの除去 A→B / C→D

awareなdatetimeからnativeなdatetimeにするのは、あまりないかもしれないが、replaceでtzinfoをNoneに設定すればできる。

ただしこれは本当にタイムゾーンを除去するだけで、時刻が変換されるわけではないので注意。

あるタイムゾーンからあるタイムゾーンへ置き換えるときはreplaceで置き換えてはいけない。前述のastimezoneを使うこと。

>>> A.replace(tzinfo=None)
datetime.datetime(2021, 4, 1, 11, 22, 33)

nativeなdatetimeでタイムゾーン変更の時間シフト B→D / D→B

nativeなdatetimeではタイムゾーンは持たないが、それを別のタイムゾーンのもの時間へシフトしたい場合。
直接変更するのではなく、replaceでaware化してからastimezoneで変換、再びreplaceでnative化する。

# UTCとみなしてあとに、JSTへ変換し、再びnative化
>>> B.replace(tzinfo=UTC).astimezone(JST).replace(tzinfo=None)
datetime.datetime(2021, 4, 1, 20, 22, 33)

2つのタイムゾーンのoffsetの差をtimedeltaで加算することでも可能だが、その場合はそのタイムゾーンのオフセットが日時によって変わることを考慮する必要になる。たとえば夏時間など。上記の方法で awareなdatetimeにしてから変換するとその問題は発生しないので安全。

非同期IOをつかったHTTPリクエストの処理 aiohttp

非同期IOを使うことでシングルプロセス、シングルスレッドでも効率的に複数のHTTPリクエストの処理を行う。

サーバー側の準備

はじめに動作確認用に複数のリクエストを処理できるHTTPサーバプログラムを用意する。

このサーバープログラム自体も非同期IOで記述することで複数のリクエストを処理するサーバーを記述できるのだが、サーバー側とクライアント側で非同期IOの説明すると、今回の論点がぼやけてくるので、ひとまずリクエストを受け付けると3秒待ってからレスポンスを返すマルチスレッドなサーバーを用意した。このプログラムの説明は本題ではないので省略。

これを起動して、別の端末からcurlで同時に3リクエストを送ってみると、3秒後に3つのレスポンスが返る。

$ python wsgi-server.py
$ seq 1 3 | xargs -P 3 -I{} curl "localhost:8000/{}"
Hello World/1
Hello World/2
Hello World/3

非同期IOによるHTTPリクエスト

サードパーティライブラリのaiohttpを使用すると、非同期なHTTPClientをかんたんに記述できる。

import aiohttp
import asyncio
import time


async def get_request(session, no):
    response = await session.get(f'http://localhost:8000/{no}')
    content = await response.text()
    print(f"{time.strftime('%X')} - {response.status} {content}", end='')


async def main():
    tasks = []
    print(f"{time.strftime('%X')} - Start")
    async with aiohttp.ClientSession() as session:
        for n in range(3):
            tasks.append(get_request(session, n+1))
        await asyncio.gather(*tasks)
    print(f"{time.strftime('%X')} - Finish")

asyncio.run(main())

実行結果

18:03:56 - Start
18:03:59 - 200 Hello World/3
18:03:59 - 200 Hello World/2
18:03:59 - 200 Hello World/1
18:03:59 - Finish

asyncio 非同期IOの基本

Python3.7以降から使用可能なasyncioモジュールを使用することで非同期処理IOが実装できる。

しくみ

非同期IO処理とは、簡単に言えば「あるIO処理の終了を待たずに他の処理をする」こと。今までであれば時間のかかる処理を呼び出せば、その処理が終了するまで処理は止まって(ブロックされて)待たなければならなかった。これが同期処理。非同期処理は処理の終了を待たずに、その間に他の処理をできる。(ノンブロックキング)

threadingやmultiprocessでも似たように、処理の終了を待たずに他の処理をすることができるが、asyncioによる非同期IO処理はそれよりももっと軽量に動作し、実際には並列して処理が動くことはなく1スレッドで動いている処理は1つだけになる。

「同時に処理が動かない」のに「処理の終了を待たずに他の処理ができる」のは奇妙に思うかもしれない。IO処理というのはCPUを使わず処理が終わるのを待っている時間がある。例えばファイルの読み込みをしている間は、CPUを使わずにファイルのデータが読み込まれるのを待っている。この時間はCPU的には十分に長い時間なのでこの時間に他のことが処理できる。

非同期IO処理は「空き時間にこの処理をして!終わったら教えて!」という細かい処理をどんどん登録して、「空いた!」「じゃあこれやって!」という小さな処理単位をイベントループというループの中でどんどん処理する。処理自体は同時に1つしかこなせないが、空き時間を有効活用していることになる。ちょうど料理中に電子レンジをONにして、チンと完了するまでに他の作業をするのに似た感じ。

注意点

処理単位のなかで、長く時間のかかるブロッキング処理をしてしまうと、従来の同期処理と同じことになってしまうため避けなければならない。

例えばtime.sleepはスリープする処理だが、これは同期処理のsleepであり、非同期IOにはasyncio.sleepという非同期用のsleepがある。間違って非同期処理の中で、time.sleepを使うと非同期処理は他の処理をプログラムはそこでブロックして他のことを処理できなくなる。

このため、今まで同期処理でやっていたことと違うコードを書くことになるので注意する必要がある。

サンプルコード

import asyncio
import time

async def say_after(delay, what):
    await asyncio.sleep(delay)
    print(f"{time.strftime('%X')} - {what}")

async def main():
    print(f"{time.strftime('%X')} - start")
    c1 = say_after(1, 'hello')
    c2 = say_after(2, 'world')
    await asyncio.gather(c1, c2) #2つの処理の終了を待つ
    print(f"{time.strftime('%X')} - finish")

# 非同期IOのエントリポイント
asyncio.run(main())

実行結果

16:10:28 - start
16:10:29 - hello
16:10:30 - world
16:10:30 - finish

1秒後にhelloと出力する処理、2秒後にworldと出力する処理を実行する。say_after関数は完了を待たずにすぐに返ってくる。asyncが設定された関数はそのような処理になる。asyncが設定された関数の処理を待つ場合にはawaitを使う。待つと書いたが実際にはこの間に他のasync処理を実行して、終わった後に戻ってくるような動きをする。

この結果、2つのsay_afterの処理はそれぞれ1秒、2秒まってから処理が行われるので全体としては2秒ですべてが終了する。

awaitが使えるのはasync関数の中だけになる。これが重要でasyncで非同期処理を使いたいと思ったときにはそこがasyncでなければならない。それを呼び出す関数もasyncでなければならない。どんどん遡ったトップレベルのエントリポイントで、asyncio.runなどから起動されていなければならない。

await.sleepをtime.sleepにすると、sleepでブロックされてその他の処理ができなくなってしまう。

import asyncio
import time

async def say_after(delay, what):
    # await asyncio.sleep(delay)
    time.sleep(delay)  # 非同期ioではないsleep
    print(f"{time.strftime('%X')} - {what}")

async def main():
    print(f"{time.strftime('%X')} - start")
    c1 = say_after(1, 'hello')    #time.sleepがブロックするのでc1の取得に1秒かかる
    c2 = say_after(2, 'world')
    await asyncio.gather(c1, c2) #2つの処理の終了を待つ
    print(f"{time.strftime('%X')} - finish")

# 非同期IOのエントリポイント
asyncio.run(main())

実行結果

11:05:37 - start
11:05:38 - hello
11:05:40 - world
11:05:40 - finish

実行結果からわかるように、startからhelloまで1秒かかり、helloからworldに2秒かかっている。

フォーマット済み文字列リテラル(f-string)

3.6からフォーマット済み文字列リテラル(f-string)が使用できるようになりました。

文字列内で変数を参照する書き方です。フォーマット指定に関してはformat関数とほぼ同じです。

>>> a = 10
>>> print(f'aの値は{a}です')
aの値は10です

>>> print(f'aの値は{a:03d}です')
aの値は010です

>>> print(f'aの値は{a:.2f}です')
aの値は10.00です

日付などのフォーマットも利用可能

import datetime
>>> now = datetime.datetime.now()
>>> print(f'nowの値は{now:%Y/%m/%d %H:%M:%S}です')
nowの値は2021/02/10 12:12:43です

参考: フォーマット済み文字列リテラル

現在時刻 ⇔ ( エポック秒 / UNIX時間 ) ⇔ datetime

現在時刻を 日付時刻型(datetime)で取得

>>> import datetime
>>> datetime.datetime.now()
datetime.datetime(2020, 1, 1, 10, 25, 37, 629103)

現在時刻を エポック秒 / UNIX時間 で取得

>>> import time
>>> time.time()
1577804400.1669202
>>> int(time.time())  # 秒で切り捨てたい場合は整数化
1577804400
>>> int(time.time() * 1000)  # ミリ秒で欲しいときは1000倍
15778044001669

エポック秒 / UNIX時間 を 日付時刻型(datetime)に変換

>>> import datetime
>>> sec = 1577804400
>>> datetime.datetime.fromtimestamp(sec)
datetime.datetime(2020, 1, 1, 0, 0)

特定のdatetime からエポック秒 / UNIX時間 に変換

>>> import datetime
>>> impot time
>>> d = datetime(2020, 1, 10, 10, 20, 30)
>>> int(time.mktime(d.timetuple()))
1578619230

ミリ秒部分も欲しい場合

>>> import datetime
>>> d = datetime.datetime(2020, 1, 10, 10, 20, 30, 123)
datetime.datetime(2020, 1, 10, 10, 20, 30, 123)
>>> int(time.mktime(d.timetuple())) + d.microsecond/1000 
1578619230.123

timeitで実行時間計測

「この関数の実行時間を計測して、もっと高速化しよう」といった時に実行時間計測を便利に行うモジュール。

import timeit

# 計測対象の関数
def number_join_by_forloop():
    s = "-"
    for n in range(10000):
        s += str(n)
    return s

# 1000回実行を繰り返した秒数
t1 = timeit.timeit('number_join_by_forloop()', number=1000, globals=globals())
print("for loop: %.3f sec" % t1)

timeit関数での実行は、関数を実行するのとは別の名前空間で実行するため、そのままでは計測対象の関数を参照できない。globals引数で実行時のグローバル領域をコピーして指定すると呼び出し側で定義した関数が参照できる(globals引数はPython3.5から)。

呼び出し側の名前空間を使用しない、単純なPythonコードであればglobals引数は不要。

Python3.5以前のglobals引数が使えない環境では、以下のように自身のモジュールをインポートすることで参照できる。

t2 = timeit.timeit('from __main__ import number_join_by_forloop; number_join_by_forloop()', number=1000)
print("map join: %.3f sec" % t2)

ワイルドカードインポート(import *)は推奨されない

from hoge import *とすることで、hogeモジュールのすべてをインポートできますが、これは雑なインポートの指定方法で意図しない変数、関数までこのモジュールの名前空間にインポートしてしまうので推奨されない。

pythonの公式のスタイルガイドであるPEP8でも非推奨となっている。

http://pep8-ja.readthedocs.io/ja/latest/#import
ワイルドカードを使った import (from import *) は避けるべきです。なぜなら、どの名前が名前空間に存在しているかをわかりにくくし、コードの読み手や多くのツールを混乱させるからです。

ワイルドカードインポートによって混乱する例

あるモジュールmain.pyがfirst.pyに依存しfirst.pyはsecond.pyに依存するとする。

main --> first --> second

first.pyでは、second.pyのすべてをワイルドカードインポートした場合、secondモジュールのすべてのグローバル変数、関数がfirst.pyの名前空間に入る。

# second.py
var_x = 1
def func1():
    pass
# first.py
from second import *
var_y = 2
def func2():
    pass

first.pyからはsecond.pyのfunc1関数を使いたいだけだったが、var_xの変数もインポートされる。

同様にmain.pyはfirst.pyをワイルドカードインポートすると、first.pyの全変数、関数がインポートされる。これにはfirst.pyがインポートしていたsecond.pyの内容までもインポートされる。

# main.py
from first import *
var_z = 3
def func3():
    pass

print(dir())

dir関数で定義された名前を確認すると、func1, func2, func3, var_x, var_y, var_zすべてが参照可能になっている。

[ ...中略..., 'func1', 'func2', 'func3', 'var_x', 'var_y', 'var_z']

意図しないモジュールからのインポートも行われるため定義位置を把握するのがとても難しくなる。
(main.pyでvar_xが参照できるのは、first.pyを参照し、second.pyのインポートからたどることでやっとvar_xの存在がわかる)

これはシンプルな例だが、カジュアルにワイルドカードインポートを使うとこれが肥大化しソースコードの可読性が著しく悪くなる。

second.pyに関数を一つ追加すると、main.pyでも自動的に参照できるようになる点も危険。

インポートする順番によって意図せず上書きする可能性がある

func1という関数が定義されたhogeモジュール。

# hoge.py
def func1():
    print("func1 called")

main.pyでは、円周率πを使用したかったために、mathモジュールのpi定数をインポートしていた。
ここから、hogeモジュールの関数を使いたいために、hogeモジュールをワイルドカードインポートをした。

# main.py
from math import pi
from hoge import *

func1()
print(pi)

main.pyスクリプトの実行結果は、以下のようになり円周率πの値が出力される。

func1 called
3.141592653589793

この状態で、hoge.pyで何気なくpiというグローバル変数を定義すると、main.pyがインポートしたpi変数を上書きしてしまうことになる。

# hoge.py
def func1():
    print("func1 called")
pi = 12345
func1 called
12345

hoge.pyで記述した内容は、piというグローバル変数を作っただけであり、mathモジュールのpi定数を上書きしたわけではない。
しかしmain.pyではワイルドカードインポートしているため、main.pyの名前空間ではpi変数は、hogeモジュールのpi変数を参照することになる。

当初main.pyを記述したときには、hogeモジュールを確認しpi変数がなく上書きされなかったのかもしれない。だがあとからhogeモジュールだけを更新するだけでmainモジュールにも影響を与えてしまっている。

意図しないインポートをしないためには、main.pyからは from hoge import func1 という形で必要なものだけインポートするのがよい。

BeautifulSoup4のチートシート(セレクターなど)

BeautiflSoup4でスクレイピングして要素を抽出するときに、よく使うセレクタをチートシート的にまとめておく。

BeautifuSoup4の使い方

スクレイピングする時にBeautifulSoup4を使うことは多いと思い。よく使うAPIやセレクターの記述方法をまとめます。
ちなみに、よく忘れてしまって「どうするんだっけ?」となるんですが、BeautifulSoup4ではxpathを使ったセレクタは存在しない。urlをわたしてHTTPリクエストを投げてくれるような機能はない。

インストール

beautifulsoup4 もしくは別名の bs4でpipからインストールする。
pip install BeautifulSoupとすると古いBeautifulSoup3になるので注意。

$ pip install beautifulsoup4
or 
$ pip install bs4

BeautifuSoupオブジェクト生成のチートシート

説明 コード例
soupオブジェクト生成(HTML文字列) BeautifulSoup('html文字列', 'html.parser')
soupオブジェクト生成(ファイル) BeautifulSoup(file_handle, 'html.parser')

第1引数はHTML文字列かファイルハンドル。
第2引数はParserライブラリを指定する。よく使用するのは html.parserlxml

html.parser はPython標準ライブラリなのでインストール不要ですぐ使えるが、速度は遅い。
lxml は高速だが別途インストールの必要なC依存ライブラリ。実行環境に依存する。

from bs4 import BeautifulSoup
# HTML文字列コンテンツ引数に生成
soup = BeautifulSoup('<html><body>hoge</body></html>', 'html.parser')

# ファイルハンドルを引数に生成
with open('index.html') as html_file:
    soup = BeautifulSoup(html_file, 'html.parser')

# URLからHTTPリクエストを投げて取得するようなAPIは無い
# requestsなどの別モジュールを使って取得する
import requests
res = requests.get('http://b.hatena.ne.jp/hotentry')
soup = BeautifulSoup(res.content, 'html.parser')

BeautifulSoup APIチートシート

説明 コード例
子要素 soup.head
タグ全検索 soup.find_all('li')
1件検索 soup.find('li')
属性検索 soup.find('li', href="html://www.google.com/")
class検索 soup.find('a', class_="first")
属性取得 first_link_element['href']
テキスト要素 first_link_element.string
親要素 first_link_element.parent
from bs4 import BeautifulSoup

soup = BeautifulSoup('''
<html>
    <head><title>example</title></head>
    <body>
        <ul>
            <li><a href="http://example.com/" class="first">example.com</a></li>
            <li><a href="http://www.google.com/" data="123">google.com</a></li>
        </ul>
    </body>
</html>''', 'html.parser')

# 子要素をたぐる
print(soup.head)        # <head><title>example</title></head>
# headの下のtitle
print(soup.head.title)  # <title>example</title>

# find_allは全検索してリストで返す
li_elements = soup.find_all("li")
print(li_elements)  # [<li><a class="first" href="http://example.com/">example.com</a></li>,
                    #  <li><a href="http://www.google.com/">google.com</a></li>]
# 見つからない場合は空のリスト
nothing_images = soup.find_all('img')
print(nothing_images)   # []

# findは検索して最初に見つかった1つ目
first_li_element = soup.find('li')
print(first_li_element) # <li><a class="first" href="http://example.com/">example.com</a></li>

# 見つからない場合はNone
nothing_image_element = soup.find('img')
print(nothing_image_element)    # None

#属性も検索条件につける
anchors_to_google = soup.find_all("a", href="http://www.google.com/")
print(anchors_to_google)    # [<a href="http://www.google.com/">google.com</a>]

# classは予約後なのでアンダースコア付加
first_link_element = soup.find("a", class_="first")
print(first_link_element)   # <a class="first" href="http://example.com/">example.com</a>

# 属性取得
print(first_link_element['href'])   # http://example.com/
# テキスト要素
print(first_link_element.string)    # example.com

# 親要素
parent = first_link_element.parent
print(parent)   # <li><a class="first" href="http://example.com/">example.com</a></li>

BeautifulSoup cssセレクタ チートシート

説明 コード例
タグ検索 soup.select('li')
1件検索 soup.select_one('li')
属性検索 soup.select('a[href="http://www.google.com"]')
属性存在 soup.select('a[data])
class検索 soup.select('a.first')
# タグ全検索してリストで返す
li_elements = soup.select("li")
print(li_elements)  # [<li><a class="first" href="http://example.com/">example.com</a></li>,
                    #  <li><a href="http://www.google.com/">google.com</a></li>]

# select_oneは検索して最初に見つかった1つ目
first_li_element = soup.select_one('li')
print(first_li_element) # <li><a class="first" href="http://example.com/">example.com</a></li>
# 見つからない場合はNone
nothing_image_element = soup.select_one('img')
print(nothing_image_element)    # None

#属性検索
anchors_to_google = soup.select('a[href="http://www.google.com/"]')
print(anchors_to_google)    # [<a href="http://www.google.com/">google.com</a>]
#data属性存在
print(soup.select('a[data]'))  # [<a data="123" href="http://www.google.com/">google.com</a>]

# class指定
first_link_element = soup.select_one("a.first")
print(first_link_element)   # <a class="first" href="http://example.com/">example.com</a>

requestsとBeautifulSoup4でシンプルなスクレイピング

はてなブックマークのホットエントリーから記事URL,タイトルを取得する例。

import requests
from bs4 import BeautifulSoup

res = requests.get('http://b.hatena.ne.jp/hotentry')
soup = BeautifulSoup(res.content, 'html.parser')

for a in soup.select('a.entry-link'):
    print(a['href'], a.text)

BeautifuSoupはインスタンス生成時にParserを指定できる。上記の例はhtml.parserを指定してあり、これはPython標準ライブラリで提供されるため、手軽に利用できるが速度的には遅い。高速なParserとしてはlxmlなどを別途インストールして指定することができる。

soup.selectではCSSセレクタを使用して走査している。

Scrapyとの比較

Scrapyでの例と比べると、単純な処理であればrequestsとBeatifulSoup4で実装したほうが簡単に書ける。

Scrapyはクローリングやスクレイピングのフレームワークで、多数のページを連鎖的にクローリングする手法などが組み込まれていて、複雑なスクレイピングが簡単に実装できる。

requestsとBeatifulSoup4で作成する際には、フレームワークの決まりごとがないぶんシンプルに記述できるが、上記のような多ページを取得することなどをやろうとすると複雑になってくる。

スクレイピングの注意事項

スクレイピングはブラウザと同じようにHTTPリクエストをサーバーに送り、データを取得するプログラムだが、プログラムの記述方法次第では、短時間に大量のリクエストをサーバーに送りつけ攻撃のようになってしまう事がある。

スクレイピングを実装する作法としては、繰り返しリクエストを送る間隔にsleep処理を入れて(最低でも1秒以上)短時間に大量のリクエストを送らないように注意する必要がある。