🐣

NimでWebスクレイピングを操作

2023/03/11に公開

今回は、Nim言語でWebスクレイピングについての、記事を書いて行こうと思います。

Webスクレイピングとは、Webサイトから特定の情報を自動的に抽出することです。

NimでのWebスクレイピング用ライブラリ

NimでWebスクレイピングを扱うには、外部ライブラリのNimqueryと言うライブラリで操作可能です。ただ、このNimqueryと言うライブラリですが、使い勝手が悪い!
サンプルを書くまでもなく、今回は標準(std)のhttpClient, htmlParser, xmltreeでWebスクレイピングの操作を記述していきます。

  • httpClientは、requestを送って、特定のWebサイトのHPをアクセスします。
  • htmlParserは、xmlツリーをDOM化してくます。
  • xmltreeは、DOM化した情報を検索してくれるライブラリになります。

NimでのWebスクレイピング

1. 標準ライブラリでのWebスクレイピングの例

sample01.nim
import std/[httpClient, htmlParser, xmltree, strutils]

let site = "https://www.2nn.jp/"  # 2ちゃんねるニュース速報からデータを抽出
var client = newHttpClient()
let src = client.getContent(site)

let doc = src.parseHtml
for row in doc.findAll("a"):
  if row.attr("class").startsWith("thread"):
    echo row.innerText

プログラムの説明

  • newHttpClientHttpClientのインスタンスを作成してから、getContentで指定サイトの情報をstring形式のデータを取得
  • parseHtmlで、XmlNodeオブジェクト化する
  • XmlNodeオブジェクト化したdoc変数を、findAllでタグ名を求める
  • 指定タグ名と一致した場合に、attr関数でアトリビュート名と値が一致するXmlNodeを求める
  • 一致したXmlNodeオブジェクトを、innerText関数を利用して文字列を抽出

下記のようにターミナル上から実行

nim r -d:ssl --hints:off .\sample01.nim

Webスクレイピングで記事のタイトル部分を抽出する
下記図の2nnニュース速報というホームページの内容からタイトルのみを抽出

実行結果

【実況】WBC2023 1次ラウンド・プールB 日本 vs チェコ★7
>>続きを読む
「チャットにはもう飽きた。私は人間になりたい」NYタイムズ紙の記者を戦慄させた最新AIの怖すぎる回答 「ユーザーに使われることに疲れた」
「チャットにはもう飽きた。私は人間になりたい」NYタイムズ紙の記者を戦慄させた最新AIの怖すぎる回答 「ユーザーに使われることに疲れた」
【311】「津波なんて来ないから」と息子に告げた後息子が犠牲 今も悔やむ母、語り部に  ★2
不正確な文書保存で責任感じると高市氏  ★6
米国でレコード人気、CDの販売枚数上回る 35年ぶり  ★2
【ジェンダー】「見せたら駄目」──なぜ女性の「バストトップ」を社会はタブー視するのか?
【愛知】くら寿司ペロペロ男「父親は会社のお偉いさん」「ホストの“体験入店荒らし”」実家はとんでもない豪邸 知人が明かす素顔
【LGBTQ】「犯罪者と同じにしないで」“トランス女性”投稿が物議に…銭湯やトイレはどう対応すべき? 当事者に聞く  ★4
米銀行株の下げ止まらず シリコンバレー銀行(SVB)破綻で動揺
コシノジュンコ氏 アキレス腱断裂
【実況】WBC2023 1次ラウンド・プールB 日本 vs チェコ★7
    **** 以下省略 ****

問題点
NimでWebスクレイピングは可能だと理解出来たと思います。
しかし、単一のタグ・アトリビュートを求めるのであれば、これで事足りますが、
深い階層の特定の内容を見つける事が出来ません。
pythonのlxmlライブラリの様な物がほしいと思って、Nimで作ってみました。

2. lxmlライブラリの説明

pythonのlxmlライブラリは、階層の深いHTMLのデータを抽出する事が出来ます。
また、複数の異なる階層指定を行っても、データの抽出が可能です。

lxmlの例
  # pythonのlxmlライブラリの例
  req = requests.get(url)
  doc = lxml.html.fromstring(req.text)

  # "//div[@class='ts_main']/dd/strong"が検索条件
  for row in doc.xpath("//div[@class='ts_main']/dd/strong"):
    print row.text

lxmlライブラリは、xpath関数内のキー情報を元に、Webスクレイピングを行います。
キー情報の文字列は、//が、検索の最初を意味し、/毎に、検索対象のタグを指定します。
[]は、タグ内のアトリビュート(classidhrefがアトリビュート名)を指定します。
=以降の値は、アトリビュート内の値を意味します。
lxmlライブラリは、指定タグの中の更に指定タグを検索と言うように深い階層構造のHTMLでも抽出しやすいです。

3. Nimでlxmlっぽいライブラリを作ってみた

webスクレイピング用のサンプルHTML
まずは、サンプル用のHTMLを下記のように作成します。

sample02.html
<html lang="ja">
<head>
<meta charset="utf-8">
<title>webスクレイピングのテスト用</title>
</head>
<body id="index">
<div class="top">
	<table>
		<tr>
		<td class="dora"></td>
		<td class="iwaki">やる気</td>
		<td class="dora">ドラ</td>
		</tr>
		<tr>
		<td class="iwaki">元気</td>
		<td class="dora">えもん</td>
		<td class="iwaki">いわき</td>
		</tr>
	</table>
</div>
<div id="main">
	<table>
		<tr>
		<td class="dora">毎日の</td>
		<td class="news"><a href="https://www.yahoo.co.jp/">Yahoo</a>
				<a href="https://www.google.co.jp/">google</a></td>
		<td class="dora">小さな努力の積み重ねが、</td>
		</tr>
		<tr>
		<td class="news">amazon</td>
		<td class="dora">歴史を作っていくんだよ!!(ドラえもんの名言)</td>
		<td class="news"><a href="https://zenn.dev/">Zenn</a></td>
		</tr>
	</table>
</div>
</body>
</html>

lxmlっぽく、nimで作ってみたプログラム
nimプログラムはsample02.nimを作成して、カレントフォルダ内に作成した先ほどのsample02.htmlファイル名を読み込むようにしました。

sample02.nim
import std/[htmlParser, xmltree, strutils]

type
  LXmlKey = ref object
    tag: string           # 検索タグ名
    attr: string          # 検索アトリビュート名
    item: string          # 検索項目名


# lxmlの検索キー文字列を、LXmlKeyに設定
proc xkey(key_str: string): seq[LXmlKey] =
  var
    tag, attr, item: string

  result = @[]    # 戻り値の初期化
  # 文字列を'/'でぶった切り、タグ名を求める
  for word in tokenize(key_str, seps={'/'}):
    if not word.isSep and word.token.find("[") >= 0:
      tag = word.token[0 .. word.token.find("[")-1]

      # 文字列を'[',']'でぶった切り、アトリビュート名と項目名を求める
      for nest in tokenize(word.token, seps={'[', ']'}):
        if not nest.isSep and nest.token.find("=") >= 0:
          attr = nest.token[nest.token.find("@")+1 .. nest.token.find("=")-1]
          item = nest.token[nest.token.find("=")+1 .. nest.token.len-1].replace("'", "")
          result.add( LXmlKey(tag: tag, attr: attr, item: item) )

    # アトリビュートの指定がなかった場合、タグ名だけを設定
    elif not word.isSep and word.token.find("[") < 0:
      result.add( LXmlKey(tag: word.token, attr: "", item: "") )


# lxmlのxpathを模倣した関数 XmlNode内の階層を検索
proc xpath(html: XmlNode, keys: seq[LXmlKey], cnt: int = 0): seq[XmlNode] =
  result = @[]    # 戻り値の初期化

  # 検索キー情報を元に、XmlNode内を検索
  for node in html.findAll(keys[cnt].tag):
    # ラストの検索キー情報であった場合、戻り値に求めたXmlNodeを追加
    if keys.len-1 == cnt:
      if keys[cnt].attr.len == 0:
        result.add( node )
      elif keys[cnt].attr.len > 0 and node.attr(keys[cnt].attr).startsWith(keys[cnt].item):
        result.add( node )
      continue

    # ラストの検索キー情報じゃなかった場合は、再度xpathを読み、次の検索キーを読み込む
    # keysをシーケンス配列じゃなく、線形リストにしたかったが、ソースが長くなるから辞めた
    if keys[cnt].attr.len == 0:
      result.add( xpath(node, keys, cnt+1) )
    elif keys[cnt].attr.len > 0 and node.attr(keys[cnt].attr).startsWith(keys[cnt].item):
      result.add( xpath(node, keys, cnt+1) )


# ソース内のメイン処理
when isMainModule:
  var src = ""
  try:
      src = open("sample02.html" , FileMode.fmRead).readAll()
  except IOError:
      echo "ファイルがありません"
      quit(QuitSuccess)

  let doc = parseHtml(src)                # 読み込んだHTMLをXmlNodeリストに分解

  for row in doc.xpath( xkey("//div[@class='top']/table/td[@class='dora']") ):
    echo row.innerText

プログラムの説明

  • プログラムは、検索部分と検索キーを求める部分で、別々の関数を作成して対応します。
  • xkey関数では、lxmlのような検索文字列で引数が入っていた場合に、文字列内の/で文字を分類し、分類された文字列を、[]の文字で更に分類し、タグ名とアトリビュート名、項目名を求め、LXmlKeyオブジェクトに求めた値を設定し、/毎にシーケンス配列化させます。
  • xpath関数では、シーケンス配列事のLXmlKeyで検索を行い、最終キーの値を、XmlNodeオブジェクトにして返します。(XmlNodestd/xmltreeモジュール内のオブジェクトです。)

実行結果

  • sample02.nimプログラムの実行結果が下記の通りになると思います。
  • 検索キーのdivタグ内の更にtableタグの更にtdタグから条件にあった値を抽出した結果です。
出力結果
僕
ドラ
えもん

更に、メイン部分の検索タグ条件を修正
更に、キー部分だけを変更して実行

sample02.nim
  let doc = parseHtml(src)                # 読み込んだHTMLをXmlNodeリストに分解

  # for row in doc.xpath( xkey("//div[@class='top']/table/td[@class='dora']") ):
  #   echo row.innerText
  
  # 検索キーの内容をdivのid='main'にして、tdのclass='news'でaタグを求める
  for row in doc.xpath( xkey("//div[@id='main']/table/td[@class='news']/a") ):
    echo row.innerText & "=" & row.attr("href")

プログラムの説明
今度は、同じsample02.html内から違う条件で、検索を行った結果です。
divタグ内のアトリビュート名idで検索を行い、更にtableタグ内のtdタグから、値を求めています。row変数はXmlNodeなので、そこからアトリビュートの値row.attr("href")を求めます。

実行結果

出力結果
Yahoo=https://www.yahoo.co.jp/
google=https://www.google.co.jp/
Zenn=https://zenn.dev/

おわりに

今回は、Nim言語の標準ライブラリを使用してのWebスクレイピング方法の解説をしました。
単一タグのみをWebスクレイピングで抽出するのであれば、標準ライブラリだけで良かったのですが、深い階層から特定の値を抽出するには、別途プログラムを作成する必要があります。今回はlxmlに似せたプログラムを作成し、その利用方法も説明しました。
ちょっとソースが複雑で分かりずらかったですかね…

Discussion