Python正規表現だけでWebクローリング

/ Python

そういう要求に出くわしたのでPythonの正規表現だけを使って特定ドメインの内部リンク先を巡回した。このやり方について記録しておく。もう少しリンクURLが複雑な場合、あるいは内部リンクに限定しない場合についても応用はある程度効くだろう。

ユニークな内部リンク

今回やった方法が使えた状況をまとめておく。

  • 対象はCMSによって生成されるものの実質的に静的ページのみ。
  • /ではじまる単純な内部リンクのみを探索する。
  • ユニークな内部リンクの総数はだいたいわかっている(多くない)。
  • サーバ負荷などは気にしない。UAもいじらなくていい。

また、importするのは以下の2点だけ。

  • re : Python標準の正規表現ライブラリ
  • requests : https通信用ライブラリ

あとはPythonが動けばok。やりたいことが明確であり迅速さを求めていたため、たいそうなWebスクレイピング・クローリング用のライブラリを持ち出していない。これらは初期の習熟に一定のリソースを割かれるのに対して上記2ライブラリは既にわかっていて他の場面にも応用が効く。

requestsによるコンテンツ取得

この部分は多用するので関数とした。Snippetかつ完全なコードをしめす。

import requests

def body_of(url):
  print(url)
  response = requests.get(url)
  content = response.content.decode('utf-8')
  match = re.search(r'^<body>.*^</body>', content, re.M | re.S)
  if match:
    return match.group(0)

Webにリクエストを送信するのはこの部分だけであるので最初にprintして通信を行ったことのプログレス表示を行う。名前のとおり<body></body>の中身だけ欲しいので正規表現によってこの部分を取り出している。

ここでも事前知識を用いていることに注意せよ。

  • <body></body>タグは必ず行頭にくる。

つかっている正規表現は難しくないが、オプションの2点はここでは重要である。

  • re.Sまたはre.DOTALL : ドットを改行を含む全ての文字列にマッチさせる
  • re.Mまたはre.MULTILINE : 複数行モード、各行の行頭が文字列の先頭と同一視される

短い方はコードは簡単になるがなんのオプションか後から見て全くわからないので考えものだな。

アンカータグのhrefを抽出

これもまずはSnippetをしめす。

matchiter = re.finditer(r'<a[^>]*? +?href="([^"]*?)"', body)
if matchiter:
  links = [a.group(1) for a in matchiter]
  for link in links:
    # do something to link here

コメントのところでbodyに含まれていたリンクに対して何かをする。正規表現は非常にナイーブに組んでおりこちらもHTMLで認識可能なaタグとはややことなる。

  • <aの間に空白がある場合は考慮しない
  • hrefが最初のattributeではない場合は考慮

反例あるかもしれないが適宜printするエンジニアリングでとりあえずターゲットドメインで動けばいいというスタンスでやっているので。

dictによる再帰

ここからは単純なプログラミングの話。Cなら再帰でもできる処理だがwhileで展開する。ホントはdo-whileが欲しかったが無念。URLをキーとしてbodyの内容を値とするC++のSTLでいうsetに相当するデータ構造が欲しいがPythonだとdictで瞬殺できる。プログラムの流れ含めてSnippetを示す。


root = 'https://kokoni_domain_name_wo_kaku/'
links_dict = { root : body_of(root) }

matchiter = re.finditer(r'<a[^>]*? +?href="([^"]*?)"', links_dict[root])
if matchiter:
  links = [a.group(1) for a in matchiter]
  for link in links:
    if link.startswith('/'):
      url = root[:-1] + link
      if not url in links_dict:
        links_dict[url] = body_of(url)

cont = True
while cont:
  cont = False
  bodys = list(links_dict.values())
  for body in bodys:
    if not body:
      continue
    matchiter = re.finditer(r'<a[^>]*? +?href="([^"]*?)"', body)
    if matchiter:
      links = [a.group(1) for a in matchiter]
      for link in links:
        if link.startswith('/'):
          url = root[:-1] + link
          if not url in links_dict:
            links_dict[url] = body_of(url)
            cont = True

あきらかにdo-whileが使える構造なんだがswiftさ重視で1つ無駄に同じようなコードチャンクを書き下した。やっていることは再帰を横方向に展開しているだけ。これ以上ユニークな内部リンクが増えなくなったらループから抜けるようになっている。同一URLで複数回リクエストが送出されることは無いようになってる。ただし前提条件に書いたようにここで想定している対象はユニークな内部リンクの数がそこまで多くない。この例は全てメモリ上でやっているがdictが限界迎えるくらいの内部リンクがあるなら改良は必要だろう。

ポイントというほどではないがstartswith('/')、つまりリンク文字列が/で始まるか否かによってそれが内部リンクかどうかを判断している。対象がCMSでそういうリンクしか出てこないことがわかっているためであるが、仮に純粋にApacheの静的ページであれば../などで親ディレクトリを参照したりする。この辺りは応用を効かせられるところだろう。あとハマったポイントはbodys = list(links_dict.values())でコピーを取っておかないとイテレーションの途中でdictが変化していると怒られる。

もうひとつ改良するとしたらrequestsでセッション管理もできるのでそれを利用する。といってもsessionはSet-Cookieによるもののことを指しているからコネクションの意味での同一セッションならrequestsの内部で勝手に行われているかもね。あらかじめ書いたようにサーバ負荷は気にしないので念のため注意。これぐらいでどうといったことになるサーバは無いだろうが。

参考URL

おそらく無限回参照するし今後も参照するだろうページだ。
re --- 正規表現操作 (Python 3.8.0 ドキュメント)