はてだBlog(仮称)

私的なブログど真ん中のつもりでしたが、気づけばWebサイト系のアプリケーション開発周りで感じたこと寄りの自分メモなどをつれづれ述べています。2020年6月現在、Elasticsearch、pandas、CMSなどに関する話題が多めです。...ですが、だんだんとより私的なプログラムのスニペット置き場になりつつあります。ブログで述べている内容は所属組織で販売している製品などに関するものではなく、また所属する組織の見解を代表するものではありません。

BeutifulSoupでお手軽DOMツリーのテキスト出力

はじめに

Python、BeautifulSoupふと思い出し企画です。

  1. Pythonスクレイピングライブラリである、BeutifulSoup4 についてオレオレ切り口でちょっとだけふれています。
  2. BeautifulSoup4やScrapyというキーワードで言うと、スクレイピングやそもそものクローラーという話題になるのですが、ここではそれらの前段であるhtml/htmlファイル群に対する探索的データ解析の視点に寄せています。
  3. よって、どちらかといえばデータ抽出などの目的よりは、例えば、あるCMSから別のCMSにデータを移行したい場合にhtmlマークアップ構造とドキュメントの共通構造を切り出し新たなスキーマを見出すために、ざっくりhtmlの構造をオーバービューしたいといった場合をイメージしています。
  4. Elasticsearchなどの検索エンジンに検索対象のhtmlドキュメントのデータを抜き出してインポートするにあたり、htmlのどのブロックを抜き出しておけば、丸っと全体を抜き出すよりもより各ドキュメントの特徴を表すブロックにフォーカスできるよね、そのような記述がされているエリアはどこかな...といったことを見出すといったケースをイメージしています。

という大層なところからはやや飛躍しますが、ひらたく言うとBeautifulSoupでコマンドラインテキストベースのDOMツリーを出力する習作もかねたツールを作ってみたという記事です。

参考リンク

本題の前に、BeautifulSoup公式へのリファレンスです。

www.crummy.com

https://www.crummy.com/software/BeautifulSoup/bs4/doc/#kinds-of-objects

https://www.crummy.com/software/BeautifulSoup/bs4/doc/#contents-and-children

BeautifulSoupなどをクローラースクレイピング用途で使う場合とオーバービュー用途で使う場合の違い

この記事の立ち位置をもっとはっきりさせるために、私の感じているクローラースクレイピング用途とオーバービューの違いに軽くふれます。

前者はなんらかの方法でおおよそ欲しいデータがマークアップのどこにあるかわかっている状態で、そのブロックを効率的に抜き出すことが第一目的です。

よって、この視点でいうと、find_allやselect、あるノードの前後や親子のノードなどを取得するparent、childrenといった、セレクタ/セレクタ風のノードへのアクセスメソッドの使いこなしがメインになります。

一方、後者はそもそも、htmlの構造がどいう傾向になっているか・例外は多くないか、欲しい情報があるノードはどこか、それに対してどんなセレクタでアクセスすれば良いか/外れが少ないかといったことを見出すことが目的です。

と、偉そうに言いましたが、後者はページ内の全てのリンクを出力して分析したり*1、そもそもDOMのツリーを気軽にみてみたいということになります。

特にDOMのツリーを気軽に(しかも複数のhtmlを横断的に目grepで大まかに把握したい)という場合には、リッチなUIよりも、CUI寄りのIFが手頃で取り回しやすいのではという発想もあるかと思います。

具体的には、次の構造のhtmlファイルをインプットに、

f:id:azotar:20200130021120p:plain

次の図のような出力が得られれば、ある程度、キーボードから手を離さずに、データの俯瞰するようなアナライズができるんじゃないかというところがこの記事の出発点です(しゴールです)。

f:id:azotar:20200130021106p:plain

※上記のhtmlは公式リファレンスのhtmlファイルをブラウザの開発者ツールで見たものと、本記事で作成してみたツールで同じhtmlを元にDOMツリーを出力したものです。

本題

Tag, NavigableString, BeautifulSoup, and Comment.

DOMツリーのテキスト出力となると、ループと再帰処理になりそうです。

BeautifulSoupはもちろんそういうこともできる(できないはずはない)のですが、先述のとおり、スクレイピングのニーズの方が強いからから、宣言的に必要なノードにアクセスするfind_allなどの紹介記事が多いように思います。

そう、意外にもループ処理・再帰処理を行うための観点を解説したものがググっても見つけられないのです。

ググラビリティが低いのか私がググり下手なのかはわかりませんが、BeautifulSoupでhtmlドキュメントの上部の方からネスト構造を辿りながらノードを取得していくにはどうするんじゃいというところなのですが、結論としては、contentsメソッド(アクセサ?)を使えば良さそうです。

ただし、このcontentsメソッドですが、使いどころというか使えどころがぱっと見分からないので注意です。

いやちゃんと分かる人には分かるのかもしれませんが、私は慣れるまで時間がかかりました。

慣れてみれば、ああ確かにこんな動作になるのねというところですが、結局のところ、次の図のような構造というところが分かればある程度すっきりします。

Tag, NavigableString, BeautifulSoup, and Comment.

の4つのオブジェクトです。

f:id:azotar:20200130021401p:plain

なので、実際は「contents」を使って

f:id:azotar:20200130021440p:plain

のように、得られたリストを「型」の種別を判定しながら、最下層(行き止まり)は、NavigableStringになるだろうから、そこまで繰り返し掘っていくと良さそう!

DOMツリー出力サンプルプログラム

続いて、DOMツリーを出力するPython/BeautifulSopuのツールプログラムの例です。

bs4.element.Tagのcontentsを再帰的に掘っていく処理としています。

ポイントは、自作のdumpdomdict関数とdomdict関数です。

インプットのDOM(BeautifulSoupオブジェクト)を、domdict関数で、タグ名を軸にネストされたdictに変換します。

そして、得られたdict(ある構造になっていることが担保されている)を再帰的に、標準出力(実際はなぜか標準エラー出力にしておきましたが...)に出力しています。

from bs4 import BeautifulSoup as sp
import bs4 
import glob
import sys

STRING_CHOP_LENGTH = 30
def domdict(bsobj: bs4.element.Tag):
    """
    htmlのdomツリーを解析して、dictの配列のネスト構造のデータを返す
        ※ タグ名:[配下のタグなどの配列] ... のようなdictを形成する
    """
    global MAX_LENGTH
    if isinstance(bsobj, bs4.element.Tag): # タグの場合
        _id = bsobj.get('id')
        _class = bsobj.get('class')
        # 「h3」「code   class=docutils literal」のようなキーを形成する
        tag = bsobj.name + '   ' + ('class=' + ' '.join(_class) if _class else '') + '   ' + ('id=' + _id if _id else '')
        #  div:[{div:[{ul:[]}]}] のようなデータを形成する(domdictを再帰的にコールしている)
        return {tag: [j for j in [domdict(i) for i in bsobj.contents] if len(j) > 0]} 
    elif type(bsobj) is bs4.element.NavigableString: # テキストの場合
        return bsobj.string.replace('\n','')[0:STRING_CHOP_LENGTH]  # タグの階層構造を明らかにする目的のため、テキストなどは読み捨てる 
    else:         
        return ''  # コメントタグは読み捨てる 


def dumpdomdict(dd: dict,n=0,nmax=100,excltags=[]):  # dictの配列のネスト構造になっている
    """
    domdictで生成したdictをツリー風に標準エラーに出力する
    """
    global CNT
    _INDENT = '  '
    _PRFX = str(CNT).zfill(6) + ': ' + str(n).zfill(len(str(nmax))) + (_INDENT * n) #出力通番と階層数値(例「000503: 009」)
    if (n >= nmax): # 深さ限界であれば何もせずreturnする
        return None
    if isinstance(dd, str):  # 文字列だった場合はその値を出力してreturnする
        print( _PRFX + (_INDENT * 30) +  dd, file=sys.stderr)
        CNT = CNT + 1
        return None
    for k, v in dd.items(): # ここまで至る場合はdict型のはずなのでキーを取り出して出力する
        if k.split(' ')[0] not in excltags: # 出力許可(NGでない)タグの場合、タグ名を出力する
            print( _PRFX + k, file=sys.stderr) 
            CNT = CNT + 1
            for i in v: # ここまで至る場合はvはlistのはずなので、順に処理する
                dumpdomdict(i, n + 1, nmax=nmax, excltags=excltags)
                

# 基本の分析対象(リンク等)
files =  [ open(i).read() for i in glob.glob('cr/*.html') ] #乱暴な方法。件数が少ない場合にしか使うべきではないがここでは簡略化のためこのとおり。
tags = 'a img script iframe'.split(' ')
attrs = 'href src src src'.split(' ')

chklist = []
chklist_jsdump = []
textdump = []

for f in files:
    CNT = 1

    bsobj = sp(f, 'html.parser')
    print(bsobj.title.text,file=sys.stderr)

    # 直下に記載のJavaScriptのテキスト取得
    chklist_jsdump.extend([i.text.replace('\n', '改行') for i in bsobj.find_all('script')])
    
    # 基本の分析対象の取得
    for t,at in zip(tags,attrs):
        print(t,at,file=sys.stderr)
        tmp = [ i.get(at) for i in bsobj.find_all(t) if i.get(at)] 
        chklist.extend(tmp)
        
    # テキストのみ取得
    textdump.append(bsobj.get_text())
    
    # dom構造を出力
    excltags = ['script','noscript','span'] # 出力しない/掘り下げないタグを設定
    dumpdomdict(domdict(bsobj),nmax=5,excltags=excltags)   # nmaxで掘り下げる最大の階層数を指定


# 基本の分析対象を全ファイルまとめてレポート

#print('\n'.join(sorted(set(chklist))))
#print('\n'.join(sorted(set(chklist_jsdump))),file=sys.stderr)

カレントディレクトリの「cr」ディレクトリに拡張子htmlのファイルをいくつか突っ込み、上記プログラムを起動すると、DOMツリーが出力されます。

言い訳など

カタい/安全なロジックを突き詰めていないわりに、思ったよりステップ数を要していますが、後述の補足のようなパラメータで挙動を変えるようにしたためで、よりシンプルなDOMツリーを出力するだけならもっとタイプ数を少なくできると思います。

このほか、複数ファイル(crディレクトリに配置されている全てのhtmlファイルについてループ)を取り扱ったりしているのと、実際はコメントアウトしているので何も出力されないリンク分析用のロジックもなぜか加えているので本題が紛れて紛らわしいかもしれませんのでご容赦ください。

ただし、これらもfind_allなどスクレイピング寄りの視点でよく使われるメソッドのシンプルな事例解説も兼ねているので、一応残しておいたというところです。

補足事項

以下、上記のプログラムの補足です。

DOMをdictにする

上記のプログラムでは、インプットのDOM(BeautifulSoupオブジェクト)を、domdict関数で、タグ名を軸にネストされたdictに変換していますが、下記のようなdictです。

<html>
<body>
<div>
  FOOOOOOOOO
</div>
<p>
  BARRRRRR
  <span>
    BAZZZZZZ
  </span>
</p>
</body>
</html>

どいうhtmlマークアップ(DOM)なら

{
  "document": [
    {
      "html": [
        {
          "body": [
            {
              "div": [
                "FOOOOOOOOO"
              ]
            },
            {
              "p": [
                "BARRRRRR",
                {
                  "span": [
                    "BAZZZZZZ"
                  ]
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}

というdictに変換する。これはある程度ルールが定まったdictであれば再帰処理で解析(今回はprint)しやすいと考えたためです。

見栄えの加工とそのオプションなど

DOMツリーを出力する際にargvなど起動パラメータまでにはしていませんが、プログラム内の定数などで、出力するDOMの深さなどを調整できるようにしています。

これにより、単にDOMツリーを出力するだけ/かつ使い捨てで良いならもっとシンプルにできるところがその分だけ複雑になっています。

具体的に調整できることおよびその他ロジックが少々膨れる要因となっているものとしては次のとおりです。

  1. STRING_CHOP_LENGTH の値を変えると、タグで囲まれたテキストの最初の数文字のみ出力するようにする。0に設定した場合はテキストは出力せず、タグの構造のみ出力する。
  2. idとclassはCSSとのインタフェースになる傾向のある属性なので大まかに把握しておきたい → これらを出力するための編集をしている。
  3. DOMツリーの出力の深さを「dumpdomdictのnmaxパラメータ」で制御できる。この例では5階層までとしている。
  4. excltagsに出力対象外とするタグ名を設定できる。
  5. glob.globであるディレクトリ配下の拡張子htmlの全てについて処理対象にするようにしている。ここではトップディレクトリを「cr」ディレクトリにしているが、「**.html」などとして最下層のディレクトリのファイルまで処理対象にすることも可能。

さいごに

今回はこれでおしまいです。

*1:実際のところこの用途のみであれば、find_allが望ましいですが、ここではスタンスの違いとして例にあげています。