HTMLでXPathを使う.

ウェブスクレイピングという技術があるそうです.ウェブサイトから情報を抽出する技術のこと.TeraTailにVBAからMSXMLを使用してHTMLをパースし、XPathで必要な情報を取得したいという趣旨の投稿があありました.そもそもMSXMLでは入力がXHTMLでない限り(つまりHTMLをXMLとした扱えない限り)MSXMLだと無理ですと書いたのですが、まあ否定するだけではソリューションになりません.WEBを当たってみると.NETのDLLをCOM化する、つまりVBAからも扱えるようにするという話は結構掲載されています.ならばVB.NET + XPathでHTMLを検索できるのか試してみました.

ともかくVisual Studioを立ち上げるのは年に1回あるかないかです.恥ずかしながらすべてを忘れてしまっています.それでも昔作ったVBのプログラムを参考にしながら、WEBを調べるとHTML Agility Pack(https://www.nuget.org/packages/HtmlAgilityPack )というので、HTMLをパースできるところはわかりました.これを使って気象庁が出しているその日の気象情報から、地点と最高気温を抜き出してXMLに落とすプログラムを構想してみました.


簡単には、該当のテーブルを選んで、見出し行を除いてtrを取得し、0番目と6番目のセルの値を取得するだけです.
ところが結果は全然うまくいってくれません.HTMLを読めることは読めているようなのですが、まともにDOMに落ちていないらしく、XMLに書き込むと文字は化けるは、最高気温もデタラメと散々でした.

そこで別の方法はないものかと探したのですが、.NETのライブラリに同様にSGMLXMLに落としてくれるオープンソースがありました.

SgmlReader - Convert (almost) any HTML to valid XML

こちらを使ってみると、なんの苦も無くほぼ一発でXMLに落とせました.そうです、HTMLというのはそもそも由緒正しきSGMLアプリケーションなので、SGMLのリーダーがあれば読めるのです.プログラムはこんな具合です.

[SgmlReaderを使ったプログラム]
Imports System.Xml
Imports System.Xml.XPath
Imports System.Text.Encoding
Imports Sgml
Module HtmlXPathModule
    Sub Main()
        Dim xw As XmlWriter = CreateXmlWriter("maxTemp.xml")
        xw.WriteStartElement("tempDataRoot", "")
        Dim sgml As SgmlReader = New SgmlReader()
        sgml.DocType = "HTML"
        sgml.IgnoreDtd = True
        Dim htmlDoc As XDocument = XDocument.Load(sgml)
        Dim nsTable As NameTable = New NameTable
        Dim nsMgr As XmlNamespaceManager = New XmlNamespaceManager(nsTable)
        nsMgr.AddNamespace("xhtml", "http://www.w3.org/1999/xhtml")
        Dim targetTrs As IEnumerable(Of XElement) = htmlDoc.XPathSelectElements("//xhtml:table[@class = 'o1']//xhtml:tr[@class != 'o1h']", nsMgr)
        For Each tr As XElement In targetTrs
            Dim targetTds As IEnumerable(Of XElement) = tr.Elements
            Dim tdArray As XElement() = targetTds.ToArray
            Dim region As XElement = tdArray(0)
            Dim maxTemp As XElement = tdArray(6)
            xw.WriteStartElement("tempData")
            xw.WriteAttributeString("region", "", region.Value)
            xw.WriteAttributeString("maxTemp", "", maxTemp.Value)
            xw.WriteEndElement()
        Next
        xw.WriteEndElement()
        xw.Close()
    End Sub

    Function CreateXmlWriter(outputPath As String) As XmlWriter
        Dim settings As XmlWriterSettings = New XmlWriterSettings()
        settings.CloseOutput = True
        settings.ConformanceLevel = ConformanceLevel.Document
        settings.Encoding = UTF8
        settings.Indent = False
        settings.NewLineChars = vbCrLf
        settings.NewLineHandling = NewLineHandling.None
        settings.OmitXmlDeclaration = False
        settings.WriteEndDocumentOnClose = False
        Dim xw As XmlWriter = XmlWriter.Create(outputPath, settings)
        Return xw
    End Function

End Module

[生成されたmaxTemp.xml(抜粋:最高気温の右の"]"は元からついています)]
<?xml version="1.0" encoding="UTF-8"?>
<tempDataRoot>
   <tempData maxTemp="27.1]" region="札幌"/>
   <tempData maxTemp="26.9]" region="稚内"/>
   <tempData maxTemp="26.0]" region="北見枝幸"/>
   <tempData maxTemp="28.0]" region="旭川"/>
   ...
   <tempData maxTemp="37.1]" region="鹿児島"/>
   ...
   <tempData maxTemp="33.0]" region="名護"/>
   <tempData maxTemp="32.7]" region="久米島"/>
   <tempData maxTemp="32.3]" region="南大東島"/>
   <tempData maxTemp="31.8]" region="宮古島"/>
   <tempData maxTemp="32.6]" region="与那国島"/>
   <tempData maxTemp="31.3]" region="西表島"/>
   <tempData maxTemp="33.1]" region="石垣島"/>
   <tempData maxTemp="" region="昭和"/>
</tempDataRoot>

この日は暑くて鹿児島は37℃を越えています.我が家も冷房がないので、このプログラムを作るのも汗だくです.

あともう一度トライしてみましたが、HTML Agility Packでも同じように動かすことができました.どうもエンコーディングがシビアらしいです.(このサイトでは)UTF-8を明示的に指定して、HTMLをいったんstringに落としてそこからパースすると動いてくれました.


XML Agility Packを使ったプログラム]

Imports System.Xml
Imports System.Text.Encoding
Imports System.Net
Imports HtmlAgilityPack
Module HtmlAgilityTestModule
    Sub Main()
        Dim xw As XmlWriter = CreateXmlWriter("maxTemp.xml")
        xw.WriteStartElement("tempDataRoot", "")
        Dim wc As WebClient = New WebClient()
        wc.Encoding = UTF8
        Dim htmlSource As String = wc.DownloadString("http://www.data.jma.go.jp/obd/stats/data/mdrr/synopday/data1s.html")
        Dim doc As HtmlDocument = New HtmlDocument()
        doc.LoadHtml(htmlSource)
        Dim trs = doc.DocumentNode.SelectNodes("//table[@class = 'o1']//tr[@class != 'o1h']")
        For Each tr In trs
            Dim tds As IEnumerable(Of HtmlNode) = tr.Elements("td")
            Dim tdArray As HtmlNode() = tds.ToArray
            Dim regionTd As HtmlNode = tdArray(0)
            Dim maxTempTd As HtmlNode = tdArray(6)
            xw.WriteStartElement("tempData")
            xw.WriteAttributeString("region", regionTd.InnerText)
            xw.WriteAttributeString("maxTemp", maxTempTd.InnerText)
            Debug.WriteLine("region={0} max-temp={1}", regionTd.InnerText, maxTempTd.InnerText)
            xw.WriteEndElement()
        Next
        xw.WriteEndElement()
        xw.Close()
    End Sub

あと、同じことをXPathでなくLINQXMLでやったらどうか?ずっと疑問に思っていました.こちらも案ずるより産むが安しでStackOverflowに質問したらすぐ回答をいただけました.LINQもなかなか強力ですね.

このXPathLINQ to XMLならどう書くのでしょうか?

という訳でお盆は暑かったのですが結構勉強になりました.普段XSLTやDITAやJavaばっかりなんですが、たまにはVisual Studioも動かすべきですね.