Python の urllib.robotparser が失敗するときの対処法

Pythonurllib.robotparse を使ってrobot.txtをパースしようとしたらハマったのでメモ。

Pythonクローラーを作成していて、URLへのアクセス許可やクロールする際の遅延時間を urllib.robotparse で取得しようとしたら、なぜか技術評論社robots.txt の内容が読み込めなくて困った。

https://gihyo.jp/robots.txt(2020/11/26現在)

User-agent: *
Disallow: /tagList
Disallow: /*?
Allow: /*?page
Allow: /*?start
Allow: /book/genre
Allow: /book/series
Allow: /book/topics

User-agent: bingbot
Crawl-delay: 5

User-agent: Pinterest
Disallow: /

Sitemap: http://gihyo.jp/sitemap.xml

robots.txt によれば、任意のユーザーエージェントに対して https://gihyo.jp/book/genre へのアクセスは許可が設定されており、ユーザーエージェント bingbot に対して Crawl-delay は5秒が設定されているが、urllib.robotparserでそれらの設定がとれない。

>>> import urllib.robotparser
>>> import urllib.request
>>> url = 'https://gihyo.jp/robots.txt'
>>> rp = urllib.robotparser.RobotFileParser(url)
>>> rp.read()
>>> rp
<urllib.robotparser.RobotFileParser object at 0x7f2678d15780>
>>> print(rp.can_fetch("*", "https://gihyo.jp/book/genre"))
False  # <- Trueが返ってきてほしい
>>> print(rp.crawl_delay("bingbot"))
None   # <- 5が返ってきてほしい

ユーザーエージェントの変更

課題 15851: Lib/robotparser.py doesn't accept setting a user agent string, instead it uses the default. - Python tracker

上記サイトによると、urllibがrobots.txtを開く際に使用しているデフォルトのユーザーエージェントがサイトからブロックされている場合があるようだ。

たしかに、urllib.request.urlopen()を直接叩いて https://gihyo.jp/robots.txt にアクセスしようとすると、403が返ってくる。

>>> import urllib.request
>>> url = 'https://gihyo.jp/robots.txt'
>>> urllib.request.urlopen(url)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python3.6/urllib/request.py", line 223, in urlopen
    return opener.open(url, data, timeout)
  File "/usr/lib/python3.6/urllib/request.py", line 532, in open
    response = meth(req, response)
  File "/usr/lib/python3.6/urllib/request.py", line 642, in http_response
    'http', request, response, code, msg, hdrs)
  File "/usr/lib/python3.6/urllib/request.py", line 570, in error
    return self._call_chain(*args)
  File "/usr/lib/python3.6/urllib/request.py", line 504, in _call_chain
    result = func(*args)
  File "/usr/lib/python3.6/urllib/request.py", line 650, in http_error_default
    raise HTTPError(req.full_url, code, msg, hdrs, fp)
urllib.error.HTTPError: HTTP Error 403: Forbidden

これを回避するには、urllib.request.install_opener()を使って、urllibにデフォルトと異なるユーザーエージェントを使うよう指示すればいい。

>>> opener = urllib.request.build_opener()
>>> opener.addheaders = [('User-agent', 'MyUa/0.1')]
>>> urllib.request.install_opener(opener)
>>> urllib.request.urlopen(url)
<http.client.HTTPResponse object at 0x7feb220ee240>
>>> res = urllib.request.urlopen(url)
>>> res.status
200 # <- OK

urllib.robotparser でもちゃんと robots.txt の内容を読めるようになった。

>>> import urllib.robotparser
>>> import urllib.request
>>> opener = urllib.request.build_opener()
>>> opener.addheaders = [('User-agent', 'MyUa/0.1')]
>>> urllib.request.install_opener(opener)
>>> url = 'https://gihyo.jp/robots.txt'
>>> rp = urllib.robotparser.RobotFileParser(url)
>>> rp.read()
>>> print(rp.can_fetch("*", "https://gihyo.jp/book/genre"))
True
>>> print(rp.crawl_delay("bingbot"))
5

rloginでviの表示が崩れるときの対処法

職場のSolarisで別のマシンにrloginでリモートログインして作業していたら、viの表示が崩れる(スクロールができないなど)という現象が起きた。

調べてみると、telnetsshにはリアルタイムに端末のサイズを取得できる機能があるが、その他の接続形式ではそれが保証されていないことがあるためなようだ。

参考サイト:

(ふつうにsshを使えという話だが、なぜか今の現場ではrloginを使う人が多いので、これだと困ってしまう。)

解決法

ログインシェルのドットファイルbashなら~/.bashrccshなら~/.cshrc)に以下を追記することで、viの表示が崩れなくなった。

eval `resize`

Pythonでスクレイピングの練習

スクレイピングに興味があったので、PythonによるWebスクレイピング 第2版を読んでいる。

GithubでJupyter notebooksが公開されていて(REMitchell/python-scraping: Code samples from the book Web Scraping with Python)、実際にコードを動かしながら読めるのでわかりやすい。3章までに読んだ内容を元に、スクレイピングの練習をしてみる。

架空のオンライン書店スクレイピング

スクレイピングの対象は以下のサイトにした。

スクレイピングの学習者向けに公開されているサイトで、架空のオンライン書店の形をしている。自由にスクレイピングしていいらしいので、とてもありがたい。

今回は、このオンライン書店内にある全ての本のタイトルと価格を抽出して表示するプログラムを作る。

ページ内のリンクを再帰的に取得する

以下のコードは、http://books.toscrape.com/ を起点として、ページ内のリンクを再帰的に辿って表示する。

from urllib.request import urlopen
from bs4 import BeautifulSoup
import re
from urllib.parse import urljoin


# すでに訪れたページのリスト
visitedPages = set()


def getLinks(pageUrl):
    global visitedPages

    html = urlopen(pageUrl)
    bs = BeautifulSoup(html, 'html.parser')

    print(pageUrl)

    for link in bs.find_all('a'):
        if 'href' in link.attrs:
            newPage = urljoin(pageUrl, link.attrs['href'])

            if newPage not in visitedPages:
                visitedPages.add(newPage)
                getLinks(newPage)


getLinks('http://books.toscrape.com/')

実行するとサイト内のすべてのページのURLが出力される。

これだけのコード量でサイト内のページをすべて取得できるのだから、再帰というものは魔法みたいだと思う。

$ python3 book-crawler.py
http://books.toscrape.com/
http://books.toscrape.com/index.html
http://books.toscrape.com/catalogue/category/books_1/index.html
http://books.toscrape.com/catalogue/category/books/travel_2/index.html
http://books.toscrape.com/catalogue/category/books/mystery_3/index.html
http://books.toscrape.com/catalogue/category/books/historical-fiction_4/index.html
http://books.toscrape.com/catalogue/category/books/sequential-art_5/index.html
http://books.toscrape.com/catalogue/category/books/classics_6/index.html
http://books.toscrape.com/catalogue/category/books/philosophy_7/index.html
http://books.toscrape.com/catalogue/category/books/romance_8/index.html
http://books.toscrape.com/catalogue/category/books/womens-fiction_9/index.html
〜〜以下省略〜〜

基本的には 『PythonによるWebスクレイピング』の第3章のコード例 とそれほど変わらないが、いくつか変えたところもある。以下で説明する。

相対URLを絶対URLに変換する

PythonによるWebスクレイピング』ではWikipediaの英語版をスクレイピングの対象としていて、内部リンクがすべて /wiki/ から始まる相対URLとなっているため、絶対URLをもとめるのが簡単になっていた(https://en.wikipedia.org とその相対URLを結合するだけで絶対URLになる)。

一方、http://books.toscrape.com/ では、

<a href="../../../../index.html">Home</a>

のようなダブルドット(..)を使った相対URLが使われているため、単純な文字列の結合だけでは絶対URLを得ることができない。

幸い、PythonにはURLを解析していい感じに分解したり結合したりできる urllib.parse.urljoin という標準ライブラリが用意されている (urllib.parse --- URL を解析して構成要素にする — Python 3.8.5 ドキュメント)。

使い方は簡単で、以下のように、第一引数に自分がいま見ているページの絶対URLを、第二引数にそのページからの相対URLを渡すと、相対URLを絶対URLにして返してくれる。

>>> from urllib.parse import urljoin
>>> urljoin('http://books.toscrape.com/catalogue/category/books/travel_2/index.html', '../../../../index.html')
'http://books.toscrape.com/index.html'

コードの以下の部分でページ内のリンクを絶対URLに変換するのに使った。

newPage = urljoin(pageUrl, link.attrs['href'])

本の情報を抽出する

単にURLを出力するだけではおもしろくないので、本のタイトルと価格を表示してみる。

商品ページの特徴

本の情報を抽出するためには、いま見ているページが商品のページなのか、それともトップページ(All products | Books to Scrape - Sandbox)やカテゴリごとのページ(Poetry | Books to Scrape - Sandbox)など商品の一覧のページなのか区別する必要がある。商品のページのときだけ本の情報を抽出したい。

URLに商品ページに固有の特徴を見つけられなかったので、ページの内容に特徴を探すことにした。

適当な商品のページ(A Light in the Attic | Books to Scrape - Sandbox)のソースを観察してみると、商品のページでは、

<article class="product_page">

のように <article> タグに商品ページであることを示すクラス属性 product_page が付与されていることがわかった。

このタグは以下のようにCSSセレクタを使って取得できるから、その結果から商品ページかどうか判断できる。

bs.select(".product_page")

タイトルの取得

次に商品ページから本のタイトルを取得する。

ブラウザの開発者ツールを使って本のタイトル部分の要素を選択すると、タイトルは単純に h1 タグの中にあることがわかる。

f:id:hk03ne:20210620184159p:plain
ge-title

したがって、タイトルは以下のように取得できる。

title = bs.h1.get_text()

本の価格

本の価格の場合はすこし面倒で、タイトルと同様に要素を調べると、<p class="price_color">£51.77</p> のように price_color というクラス属性が付与されているのが価格だとわかる。

f:id:hk03ne:20210620184229p:plain
get-price

ただし、本によっては他の本の情報を下の方に表示しているものがあるため(例えばThe Black Maria | Books to Scrape - Sandbox)、ページの上の方に載っているメインの商品の価格のみを取得できるようにしたい。開発者ツールを使ってメインの商品の情報の部分を選択すると、product_main というクラス属性が付与された div タグで囲まれていることがわかる。

f:id:hk03ne:20210620184249p:plain
get-product-main

したがって、本の価格は以下のように取得できる。

price = bs.select_one(".product_main>.price_color").get_text()

最終的なコード

以上を組み合わせて、最終的なコードは以下のようになった。

from urllib.request import urlopen
from bs4 import BeautifulSoup
import re
from urllib.parse import urljoin


# すでに訪れたページのリスト
visitedPages = set()


def getLinks(pageUrl):
    """
    ページ内のリンクを再帰的に辿って、タイトルと価格およびURLを表示する

    Parameters
    ----------
    pageUrl : str
        スクレイピングするページのURL

    """
    global visitedPages

    html = urlopen(pageUrl)
    bs = BeautifulSoup(html, 'html.parser')

    try:
        if bs.select(".product_page"):
            title = bs.h1.get_text()
            price = bs.select_one(".product_main>.price_color").get_text()
            print("{}, {}, {}".format(title, price, pageUrl))
    except AttributeError:
        pass

    for link in bs.find_all('a'):
        if 'href' in link.attrs:
            newPage = urljoin(pageUrl, link.attrs['href'])

            if newPage not in visitedPages:
                visitedPages.add(newPage)
                getLinks(newPage)


getLinks('http://books.toscrape.com/')

実行すると、すべての本のタイトル、価格、商品のページのURLが出力された。

$ python3 book-crawler2.py
The Long Shadow of Small Ghosts: Murder and Memory in an American City, £10.97, http://books.toscrape.com/catalogue/the-long-shadow-of-small-ghosts-murder-and-memory-in-an-american-city_848/index.html
Dark Notes, £19.19, http://books.toscrape.com/catalogue/dark-notes_800/index.html
Amid the Chaos, £36.58, http://books.toscrape.com/catalogue/amid-the-chaos_788/index.html
Chasing Heaven: What Dying Taught Me About Living, £37.80, http://books.toscrape.com/catalogue/chasing-heaven-what-dying-taught-me-about-living_797/index.html
The Activist's Tao Te Ching: Ancient Advice for a Modern Revolution, £32.24, http://books.toscrape.com/catalogue/the-activists-tao-te-ching-ancient-advice-for-a-modern-revolution_928/index.html
The Four Agreements: A Practical Guide to Personal Freedom, £17.66, http://books.toscrape.com/catalogue/the-four-agreements-a-practical-guide-to-personal-freedom_970/index.html
We Are All Completely Beside Ourselves, £24.04, http://books.toscrape.com/catalogue/we-are-all-completely-beside-ourselves_301/index.html
〜〜以下省略〜〜

まとめ

PythonによるWebスクレイピング』の第3章までで学んだ内容を元に、架空のオンライン書店から本の情報をスクレイピングする練習をしてみた。

コードを汎用的にしたり、集めたデータをDBに蓄積したりするのは、もっと後の章をよんでから。

WSL2 を有効化したら VirtualBox が動作しなくなったので戻した

WSL2を有効化したところ、VirtualBoxに入れていたUbuntuが起動しなくなってしまった。 (ログイン画面までは表示されるが、ログイン後にデスクトップ画面がいつまでたっても表示されない。)

WSL2 を有効化することで Hyper-V が有効化され、VirtualBox がこれまで使用していた仮想化機構(VT-X)が無効化されるのが原因のようだった。

WSL 2 についてよく寄せられる質問 | Microsoft Docs

VirtualBox 6.0 以降は Hyper-V と共存できるようになったはずだが、環境によってはうまく動かない場合があるようだ。

VirtualBoxが使えないのは困るので、とりあえず WSL1 に戻した。

手順

PowerShellを起動し、以下のコマンドで既定のバージョンを WSL1 に戻す。

wsl --set-default-version 1

また、自分の場合、WSL1 で使っていた Ubuntu のバージョンを WSL2 に変更していた(wsl --set-version Ubuntu 2 を実行した)ので、以下のコマンドで WSL1 に戻した。

wsl --set-version Ubuntu 1

PowerShellを管理者として起動し、以下のコマンドで VirtualMachinePlatform を無効化する。

dism.exe /online /Disable-Feature /featurename:VirtualMachinePlatform

マシンの再起動を求められるので、それに従って再起動すると、VirtualBoxが動くようになった。

dism コマンドによる Windows の機能の無効化については、以下のサイトを参考にした。

無印良品 USBデスクファンの分解と掃除

暑くなってきたので、無印良品のUSBデスクファンを引っ張り出してきた。残念なことにほこりがついていたので、分解して掃除した。

この製品はファンの後ろ側の部分が簡単に外れるようになっているけど、一見しただけでは外し方がわかりにくいようになっている。

ファンの後ろ側の部分をつかんで、(ファンの後ろ側を手前として)反時計周りに回すと、分離できる。

f:id:hk03ne:20160418195743j:plain

また、後ろ側の羽は引っ張るだけで簡単にとれる。

f:id:hk03ne:20160418195804j:plain

さらに、ドライバを使えば前側の部分も外れるみたいだが、ドライバがないのでできなかった。

すこしやりづらかったが、ウェットティッシュで拭いて掃除は終わり。

f:id:hk03ne:20160418200322j:plain

これで気持ちよく使えるようになった。