Python3でさらっとクロールする用事があったので、requests/beautifulsoup/reppyあたりを利用した処理について調べてみる。
requestsはPython3でHTTPリクエストする際の便利ツール。beautifulsoupはスクレイピング。reppyはrobots.txt周りを見てくれる機能。
pip利用。
$ pip install requests $ pip install beautifulsoup4 $ pip install reppy
まずは適当なサイトにリクエストをして、コンテンツの内容を文字列で取得するあたり。
import requests # うちのサイトにリクエスト resp = requests.get('http://www.mwsoft.jp/') # ステータスコード取得 resp.status_code #=> 200 # コンテンツの取得(byte配列) resp.content #=> b'<!DOCTYPE html>\n<html>\n\n<head>\n <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />\n <title>Top Page | mwSoft</title>\n ... # コンテンツの取得(文字列) resp.text #=> <!DOCTYPE html>\n<html>\n\n<head>\n <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />\n <title>Top Page | mwSoft</title>\n ...
うちのサイトにリクエストしてみたところ、文字コードの判別がうまくできておらず、browser.response.textを取得したところ化けらった。
# UTF-8なのにISO-8859-1が取れてる resp.encoding #=> ISO-8859-1
これはうちのサイトがmetaタグ内で文字コードの表明をしていること、header内で文字コードを指定していないことが原因と思われる。
requestsにはheaderとmetaタグの双方を見て文字コードを判別する機能が付けられてはいる。内部的にはrequests.utilsの各機能が呼ばれている。
# contentのmetaタグからcharsetを取ってもらう requests.utils.get_encodings_from_content(resp.text) #=> utf-8 # headerから取るとISO-8859-1 requests.utils.get_encoding_from_headers(resp.headers) #=> 'ISO-8859-1'
上記はうちのサイトで実行した結果。headerには特にcharsetを設定していないのだが、textが設定されていると勝手にISO-8859-1が指定されてしまうらしい。
そこで下記のような記述で、headerでISO-8859-1が取れ、且つmetaタグからencodingが取れていた場合に限り、metaタグを優先するというコードを書いてみる。
content_encodings = requests.utils.get_encodings_from_content(resp.text) header_encoding = requests.utils.get_encoding_from_headers(resp.headers) if header_encoding == 'ISO-8859-1' and len(content_encodings) > 0 and content_encodings[0] != 'ISO-8859-1': resp.encoding = content_encodings[0] # browser.response.encodingを設定後にtextを見ると、ちゃんと化けずに取得できる resp.text
これで無事文字コードが取得できた、と思ったけどget_encodings_from_contentはDeprecatedでそのうちなくなるのだとか。
beautifulsoupはそのへんも見てくれるので、文字コードについてはrequestsでは処理せずにbyte配列でbeautifulsoupに渡した方が精度が良さそう。
コンテンツのbyte配列から文字コードを推測したい場合は、chardetが使える。
pip install chardet
実行してみる。
import chardet chardet.detect(resp.content) #=> {'confidence': 0.99, 'encoding': 'utf-8'}
confidence(信頼度)0.99でutf-8だと推測された。
google.comで実行してみる。
resp = requests.get('http://www.google.com/') chardet.detect(resp.content) #=> {'confidence': 0.99, 'encoding': 'SHIFT_JIS'}
あれ、SHIFT_JISと言われた。ASCII文字しか入ってない場合はSHIFT_JISになるのだろうか。
と思ったけど下記の例だとasciiと言われる。
chardet.detect("foobar".encode()) #=> {'confidence': 1.0, 'encoding': 'ascii'}
www.google.comに我が家のIPからrequestsでリクエストするとSHIFT_JISで返るようだ。
コンピュータリソースに余裕があるなら、chardetを使った方がより安全。
クロールしているとたまに数MBもあるようなページに出くわすことがある。そうした場合はStreamでbyte単位で値を収集し、指定サイズ以上になったら切り上げることで、大量データをfetchすることを避けられる。
下記は10byteずつ10回だけcontentの中身を取っている。こうすれば100byteだけ取れて残りは取得されないことになる。
chunk_size = 10 with requests.Session() as sess: resp = sess.get('http://www.mwsoft.jp/', stream=True) chunks = [] for count, chunk in enumerate(resp.iter_content(chunk_size)): print(chunk) chunks.append(chunk) if count > 10: break content = b"".join(chunks) print(content) #=> b'<!DOCTYPE html>\n<html>\n\n<head>\n <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />\n <title>Top Page'
10byteずつというのは大げさなので、1024 * 10(requestsのCONTENT_CHUNK_SIZE)くらいを設定して100回くらい回せば1MBあたりで取得をやめる処理になる。
この処理だとラストあたりでbyteが切れるのが悩ましいところ。iter_linesを使えば改行のところで区切れるようになるのだけど、そうすると改行なしのデータの場合にまた困る。
下記は1行ずつ読み込んで100byteまで達したら読み込みをやめる記述。
byte_len = 0 with requests.Session() as sess: resp = sess.get('http://www.mwsoft.jp/', stream=True) chunks = [] for chunk in resp.iter_lines(): print(chunk) byte_len += len(chunk) chunks.append(chunk) if byte_len > 100: break content = b"".join(chunks) print(content)
robobrowser単体ではrobots.txtは特に参照してないようなので(Issueには上がっていた)、reppyを使って判別する処理を噛ませる。
from reppy.cache import RobotsCache robots = RobotsCache() # robots.txt的に許可されているか確認 robots.allowed('http://www.mwsoft.jp/', 'python program') #=> True # キャッシュされていることを確認 robots._cache #=> {'www.mwsoft.jp':}
うちのサイトはrobots.txtのテスト用にMyRoboXYZというエージェントは弾くようになっている。
robots.allowed('http://www.mwsoft.jp/', 'MyRoboXYZ') #=> False
リクエスト前に必ずreppyを噛ませるようにすれば、クロール禁止のサイトを
Python3のurllibにはrobotparserモジュールがいる。下記のような記述でリクエストの可否を確認可能。
import urllib.robotparser robot_parser = urllib.robotparser.RobotFileParser() # 全リクエストを拒否するrobots.txtを読みこませる robot_parser.set_url('http://www.mwsoft.jp/test/robots/disallow_all/robots.txt') robot_parser.read() # リクエストが許可されるか確認 → 拒否 robot_parser.can_fetch('*', 'http://www.mwsoft.jp/test/robots/disallow_all/robots.txt') #=> False
reppyと比べると、自前でrobots.txtのパスを指定しないといけなかったり、キャッシュ機能は自前で書かないといけなかったりするけど、そこまで面倒でもないので依存ライブラリを減らす目的で自前実装するのも十分に選択肢として考えられる。
nofollow(リンクたどるな)/noarchive(アーカイブするな)も一応守っておきたい。
このあたりはBeautifulSoupを利用する。
import requests resp = requests.get('http://www.mwsoft.jp/test/robots/meta_nofollow/no_follow.html') from bs4 import BeautifulSoup soup = BeautifulSoup(resp.content, 'html.parser') nofolows = soup.find_all('meta', attrs={'content': 'nofollow'}) #=> [<meta content="nofollow" name="robots"/>] len(nofolows) > 0 and nofolows[0]['name'].lower() == 'robots' #=> True
findAll('a', href=True)でhrefが設定されたanchorだけ辿れる。
リンクは相対パスが設定されていることもあるので、urljoinを設定することで絶対パスでのURLの取得も可能。
import requests from bs4 import BeautifulSoup from urllib.parse import urljoin url = 'http://www.mwsoft.jp/' resp = requests.get('http://www.mwsoft.jp/') soup = BeautifulSoup(resp.content, 'html.parser') for a in soup.findAll('a', href=True): print(a['href']) print(urljoin(url, a['href']))
import requests from bs4 import BeautifulSoup from urllib.parse import urljoin from reppy.cache import RobotsCache # ユーザエージェントを適当に決めておく DEFAULT_USER_AGENT = 'requests' # 取得するコンテンツの最大長を決めておく MAX_CONTENT_LENGTH = 1000000 # 1MBくらい # robots.txt用の例外 class RobotsException(requests.RequestException): ''' robots.txt not allowed. ''' # meta content=noarchive name=robotsが設定されてる際の例外 class MetaNoArchiveException(requests.RequestException): ''' meta noarchive robots. ''' def check_robots(url, user_agent=DEFAULT_USER_AGENT): ''' robots.txtの確認 ''' robots = RobotsCache() return robots.allowed(url, user_agent) def check_noarchive(soup): ''' metaタグにnoarchiveが設定されてないか確認 ''' noarchive = soup.find_all('meta', attrs={'content': 'noarchive'}) return len(noarchive) > 0 and noarchive[0]['name'].lower() == 'robots' def get(url, max_content_len=MAX_CONTENT_LENGTH): ''' requestsで指定urlにリクエストする。 取得サイズの上限機能付き。robots.txt確認付き。noarchive確認付き。 ''' # robots.txtのチェック if not check_robots(url): raise RobotsException(url) # 指定byteまでcontentを読み込み byte_len = 0 with requests.Session() as sess: resp = sess.get('http://www.mwsoft.jp/', stream=True) chunks = [] for chunk in resp.iter_lines(): byte_len += len(chunk) chunks.append(chunk) if byte_len > max_content_len: break content = b"".join(chunks) return resp, content def store(url, content): # do something pass resp, content = get('http://www.mwsoft.jp/') soup = BeautifulSoup(content, 'html.parser') if check_noarchive(soup): raise MetaNoArchiveException(url)