読者です 読者をやめる 読者になる 読者になる

( ꒪⌓꒪) ゆるよろ日記

( ゚∀゚)o彡°オパーイ!オパーイ! ( ;゚皿゚)ノシΣ フィンギィィーーッ!!!

「HTMLスクレイピング in Scala」を改造しながら俺流Scalaコーディング手法を紹介してみる

短い中にいろいろなエッセンスが詰まったよいサンプルだと思ったので、id:noire722さん(@noire722)が書いた「HTMLスクレイピング in Scala」を、俺流に書き直してみました。


HTMLスクレイピング in Scala その2 - cignoir の日記


書き直す過程でどういう考えで修正したかを解説します。

第1段階

URLからList[String]を取得する関数を修正

まず、getSourceを見てみました。

def getSource(url: String, toParse: Boolean): List[String] = {
  var src = Source.fromURL(url, "ISO-8859-1").getLines.toList

  var charset: String = null
  val regex = new Regex("""charset[ ]*=[ ]*[0-9a-z|\-|_]+""")
  for(line <- src){
    val lower = line.toLowerCase
    if(lower.contains("content") && lower.contains("charset")){
      charset = regex.findFirstIn(lower).get
        charset = charset.split("=")(1).trim
    }
  }
  if(charset != null) src = Source.fromURL(url, charset).getLines.toList
  if(toParse) parse(src) else src
}

気になった点はこんなところです。

  • getSourceという名前なのにList[String]を返している
  • parseするのはgetSourceの仕事じゃない気がする
  • charsetの判定と実際のHTMLの取得で2回リクエストを投げている
  • charset判定で、正規表現を使っているが、キャプチャはしていない

なので、このような修正を行いました。

  • URLConnectionから生のByte列をBufferに入れて、charsetの判定とcharsetに応じたエンコードのそれぞれで再利用できるようにする
  • charsetの判定は、正規表現のパターンマッチでキャプチャするようにする
  • parseするのは別の関数に任せる


以下が修正したgetSource関数です。

def getSource(url: String ) = {
  // java.net.URL#openStreamでInputStreamを取得する
  val in = new URL(url).openStream
  
  // Stream.cotinuallyにInputStreamからByteを返す( () => Byte型の)関数を渡し、
  // EOFが返るまで読み込む
  val buf = Stream.continually{ in.read.byteValue }.takeWhile{ -1 != }.toArray

  // scala.io.Source#formBytesなどは、implicit parameterで
  // scala.io.Codecオブジェクトを取るので、
  // ブロックの中でBufferからcharsetを特定し、Codecオブジェクトを設定する
  implicit val codec = {
  
    // Byte配列のBufferから、とりあえず"ISO-8859-1"で
    // 読み込むようSourceオブジェクトを作る
    val src = Source.fromBytes(buf,"ISO-8859-1")
    
    // charsetを取り出すための正規表現
    // WrappedString#rで文字列からRexexオブジェクトを生成できる
    val Charset = """.*content.*charset\s*=\s*([0-9a-z|\-|_]+).*""".r
    
    // 一行づつ読み込み、正規表現に一致する行からchasetの文字列を取り出して、
    // 最初にマッチしたものからscala.io.Codecオブジェクトを作る
    src.getLines.collect {
      case Charset(cs) => cs
    }.toTraversable.headOption.map{ cs => Codec(cs) }.getOrElse{Codec.default }
  }
  
  // 上で設定したcodecが暗黙に渡されている。以下の呼び出しと同じ
  // Source.fromBytes(buf)(codec)
  Source.fromBytes(buf)
}

Optionの使い方は、このあたりの記事を参照してください。

ScalaのOptionステキさについてアツく語ってみる - ゆろよろ日記

parse関数を直す
def parse(src: List[String]): List[String] = {
  def removeTag(target: String): String = {
    val regex = new Regex("""<.+?>|\t""")
    regex.replaceAllIn(target, "")
  }
  (for(str <- src) yield {removeTag(str)}).filter(_.length != 0)
}


文字列の中のタグを取り除く処理をしているparse関数ですが、String#replaceAllを使えば中のremoveTag関数は不要になります。あと、個人的にはfor式より高階関数のほうがこのみなので、mapとfilterに書き換えました。

def parse(src: Iterable[String]) = 
  src.map{ _.replaceAll("""<.+?>|\t""","") }.filter{ _.nonEmpty }


引数は、List[String]である必要はないので、もう少し抽象度の高い型であるIterableを取るようにしました。

完成版

downloadがAnyRefをもらうようになっているのがなんとなく気になったので、細かく関数を分けてみました。

import scala.io.{Codec, Source}
import java.io._
import java.net.URL

object HtmlScraper {

  def getSource(url: String ) = {
    val in = new URL(url).openStream
    val buf = Stream.continually{ in.read }.takeWhile{ -1 != }.map{ _.byteValue}.toArray

    implicit val codec = {
      val src = Source.fromBytes(buf,"ISO-8859-1")
      val Charset = """.*content.*charset\s*=\s*([0-9a-z|\-|_]+).*""".r
      src.getLines.collect {
        case Charset(cs) => cs
      }.toTraversable.headOption.map{ cs => Codec(cs) }.getOrElse{Codec.default }
    }
    Source.fromBytes(buf)
  }

  def write( src:Iterable[String], fileName:String ):Unit = {
    var bw: BufferedWriter = null
    try{
      bw = new BufferedWriter(new FileWriter(fileName))
      bw.write( src.mkString(System.getProperty("line.separator")))
    } finally {
      if(bw != null) bw.close
    }
  }

  def download(url:String, fileName:String, toParse:Boolean = true ):Unit = {
    val src = getSource(url).getLines.toSeq
    if( toParse ) parseAndWrite( src, fileName) else write(src, fileName )
  }

  def parseAndWrite(src:Iterable[String], fileName:String ):Unit =
    write( parse(src), fileName )

  def parse(src: Iterable[String]) = 
    src.map{ _.replaceAll("""<.+?>|\t""","") }.filter{ _.nonEmpty }

}

第2段階

現状はHtmlScraperオブジェクトに機能が集中していて、いわばstaticメソッドの集合になっているので、適切なクラスに分割します。

トレイトの導入

まず、Htmlを持つトレイトを導入します。

trait ScrapedHtml {
  val src:Iterable[String]

  def write( fileName:String ):Unit = {
    import scala.util.control.Exception._
    allCatch.opt{ 
      new BufferedWriter(new FileWriter(fileName))
    }.foreach{ bw =>
      allCatch.andFinally{ bw.close } {
        bw.write( src.mkString(System.getProperty("line.separator")))
      }
    }
  }

  def parse:ScrapedHtml
}

このScrapedHtmlトレイトは、保持しているHTMLの文字列をファイルに書き出す機能と、タグを除去する機能をもちます。HTML本体は、抽象変数valにIterable[String]型で持ちます。


ついでに、ファイルを書き出す処理はimport scala.util.control.Exceptionを利用しています。これで全てのvarが消えました。

Scalaでの例外処理 - Either,Option,util.control.Exception - ゆろよろ日記


つぎに、このScrapedHtmlトレイトを実装する具象クラスを用意します。HTMLの状態として、生のままかparseされているかのふたつの状態を持つので、それぞれをクラスにします。

// 生のHTMLを持つクラス
case class RawHtml(src:Iterable[String]) extends ScrapedHtml {
  def parse = 
    ParsedHtml(src.map{ _.replaceAll("""<.+?>|\t""", "") }.filter{ _.nonEmpty })
}

// parseされたHTMLを持つクラス
case class ParsedHtml(src:Iterable[String]) extends ScrapedHtml{
  def parse:ScrapedHtml = this
}


RawHtmlクラスのparseを呼び出すと、タグを除去したHTMLをもつParsedHtmlクラスのインスタンスが返されるわけです。ParsedHtmlクラスは、それ以上状態遷移できないため、parseは自分自身を返します。

HtmlScraperオブジェクトの修正

トレイトを導入してクラスを分割したので、HtmlScraperオブジェクトはURLからScrapedHtmlトレイトをmixinされたオブジェクトを返すように修正します。

object HtmlScraper {

  def apply(url:String):ScrapedHtml =  RawHtml(getSource(url).getLines.toSeq)

  def getSource(url: String ) = {
    val in = new URL(url).openStream
    val buf = Stream.continually{ in.read }.takeWhile{ -1 != }.map{ _.byteValue}.toArray

    implicit val codec = {
      val src = Source.fromBytes(buf,"ISO-8859-1")
      val Charset = """.*content.*charset\s*=\s*([0-9a-z|\-|_]+).*""".r
      src.getLines.collect {
        case Charset(cs) => cs
      }.toTraversable.headOption.map{ cs => Codec(cs) }.getOrElse{Codec.default }
    }
    Source.fromBytes(buf)
  }

  def download(url:String, fileName:String, toParse:Boolean = true ):Unit = {
    val html = if(toParse) HtmlScraper(url).parse else HtmlScraper(url)
    html.write(fileName)
  }
}

charsetを特定する処理では、今までは全ての行を読み込むようになっていたので、PartialFunctionを導入して最初に正規表現にマッチした時点で探索を打ち切るようにしました。

ScalaのPartialFunctionが便利ですよ - ゆろよろ日記


HtmlScraperオブジェクトに、applyメソッドを定義しました。これでHtmlScraper("http://d.hatena.ne.jp/yuroyoro")のような形でScrapedHtmlオブジェクトを取得できます。


実際には、このように利用します。

scala> val html = HtmlScraper("http://d.hatena.ne.jp/yuroyoro")
html: ScrapedHtml = RawHtml(Stream(<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">, ?))

scala> html.src.take(5).foreach{println}
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=euc-jp">
<meta http-equiv="Content-Style-Type" content="text/css">

scala> html.src.take(10).foreach{println}
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=euc-jp">
<meta http-equiv="Content-Style-Type" content="text/css">
<meta http-equiv="Content-Script-Type" content="text/javascript">
<title>ゆろよろ日記</title>
<link rel="start" href="./" title="ゆろよろ日記">
<link rel="help" href="/help" title="ヘルプ">
<link rel="prev" href="/yuroyoro/?of=5" title="前の5日分">

scala> html.parse
res50: ScrapedHtml = ParsedHtml(Stream(ゆろよろ日記, ?))

scala> html.parse.src.take(1).foreach{println}
ゆろよろ日記


完成版はGistにあります。

yuroyoro's
gist: 584154 — Gist

第3段階

まぁこれでも充分ですが、もう少し洗練させてみます。


ScrapedHtmlオブジェクトが持つHTMLにアクセスするには、メンバーであるsrcにアクセスする必要がありました。実際は、ScrapedHtmlはString型のコレクションといえるので、ScrapedHtmlをScalaのコレクションのように使えるようにしてみます。

trait ScrapedHtml extends Iterable[String] with IterableLike[String, ScrapedHtml]{

  val src:Iterable[String]

  import scala.collection.generic.CanBuildFrom
  import scala.collection.mutable.{ListBuffer, Builder}

  def newTo(from:List[String]):ScrapedHtml
  def iterator = src.iterator
  
  override def newBuilder:Builder[String, ScrapedHtml] = new ListBuffer[String] mapResult {x => newTo(x) }
  implicit def canBuildFrom: CanBuildFrom[ScrapedHtml, String, ScrapedHtml] = new CanBuildFrom[ScrapedHtml, String, ScrapedHtml] {
    def apply(from: ScrapedHtml):Builder[String, ScrapedHtml] = newBuilder
    def apply() = newBuilder
  }

  def write( fileName:String ):Unit = {
    import scala.util.control.Exception._
    allCatch.opt{ 
      new BufferedWriter(new FileWriter(fileName))
    }.foreach{ bw =>
      allCatch.andFinally{ bw.close } {
        bw.write( src.mkString(System.getProperty("line.separator")))
      }
    }
  }

  def parse:ScrapedHtml
}

case class RawHtml(src:Iterable[String]) extends ScrapedHtml {
  def newTo(from:List[String]) = RawHtml(from)
  def parse = 
    ParsedHtml(src.map{ _.replaceAll("""<.+?>|\t""", "") }.filter{ _.nonEmpty })
}

case class ParsedHtml(src:Iterable[String]) extends ScrapedHtml{
  def newTo(from:List[String]) = ParsedHtml(from)
  def parse:ScrapedHtml = this
}


完成版はGistにあります。

yuroyoro's
gist: 584191 — Gist


ScrapedHtmlトレイトは、ScalaのコレクションであるIterable[String] と IterableLike[String, ScrapedHtml]のふたつのトレイトを継承しています。これらのトレイトを継承して、いくつかの抽象メソッドを実装することで、Scalaのコレクションとして振る舞うことができるようになります。


これで、HtmlScraper("http://d.hatena.ne.jp/yuroyoro").dropWhile{ _.startsWith("

scala> val html = m.HtmlScraper("http://d.hatena.ne.jp/yuroyoro")
html: ScrapedHtml = line203(<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">, <html>, <head>, <meta http-equiv="Content-Type" content="text/html; charset=euc-jp">, <meta http-equiv="Content-Style-Type" content="text/css">, <meta http-equiv="Content-Script-Type" content="text/javascript">, <title>ゆろよろ日記</title>, <link rel="start" href="./" title="ゆろよろ日記">, <link rel="help" href="/help" title="ヘルプ">, <link rel="prev" href="/yuroyoro/?of=5" title="前の5日分">, , <link rel="stylesheet" href="http://d.st-hatena.com/statics/css/base.css?e467e1bb30f733504d0cf80b95ab397e905752bc" type="text/css" media="all">, <link rel="stylesheet" href="http://d.st-hatena.com/statics/css/headerstyle_wh.css?7aa7b098c9a33364e1ac761b34c955da48e90cf7" type="text/css" media="all">, , , , <link rel="alt..

scala> html.dropWhile{ _.startsWith("<body") == false }
res52: ScrapedHtml = line203(<body>, , <div id="simple-header">, ,   <a href="http://www.hatena.ne.jp/"><img src="/images/hatena-simple_wh.gif" alt="Hatena::" title="Hatena::" id="logo-hatena" width="65" height="17"></a><a href="/"><img src="/images/diary-simple_wh.gif" alt="Diary" title="Diary" id="logo-diary" width="43" height="17"></a>,   <form method="get" action="/search" class="search-form">,     <input type="text" class="search-word" name="word" value=""><input name="name" value="yuroyoro" type="hidden"><input type="submit" name="diary" value="日記" class="search-button"><input type="submit" name="submit" value="検索" class="search-button">,   </form>,   <ul class="menu">,     <li><a href="/yuroyoro/">ブログトップ</a></li>,     <li><a href="/yuroyoro/archive">記事一覧</a></li>, , , ,     <li...

scala> html.parse                                      
res53: ScrapedHtml = line203(ゆろよろ日記, <!-- , @charset "UTF-8";, /* 基本---------------------*/  , * {, margin: 0;, padding: 0;, }, img {, border:1px solid #666666;, margin:0pt;, padding:0pt;, }, li {, list-style-image:none;, list-style-position:outside;, list-style-type:circle;, }, a {, color: #5465FF;, text-decoration: none;, }, a:link {, text-decoration:none;, font-style: normal;, color: #5465FF;, }, a:visited {, color:#47A6FF;, text-decoration:none;, font-style: normal;, }, a:hover {, color:#7F92FF;, text-decoration:none;, }, a img {, border:1px solid #666666;, }, h1, h2, h3, h4, h5 {, font-size:100%;, font-weight:500;, }, /* メイン---------------------*/  , body{, margin:0pt;, padding:0pt;, text-align:center;, background-color: #FFFFFF;, background-attachment: fixed;, background-image: ...

scala> html.parse.drop(1000) 
res58: ScrapedHtml = line203(ビリヤード, ねた, 最新タイトル, [scala]Scalaで&#60;:&#60;とか=:=を使ったgeneralized type constraintsがスゴすぎて感動した話, [勉強会][scala]Scala座#01で「これまでのScala これからのScala」という発表をしたんだぜ, [勉強会][Java] JVM勉強会「わかる!JavaVM ― 2時間でわかる?JavaVM入門」開催しました, [scala]ATNDでTwitter連携を設定している人のTwitterIDを抜き出すスクリプト書いたよ, [scala] LL TigerのLanguage UpdateでScalaの話をしてきました, [scala]Scalaでの例外処理 - Either,Option,util.control.Exception, [ネタ]ハイパーおちんちんタイム転送プロトコル Hyper Otintin Time Transfer Protocol (HOTTP/1.0), [scala]ScalaのOptionステキさについてアツく語ってみる, [scala] パターンマッチをもっと便利に - extractor(抽出子)による拡張, [scala]Scalaの奇妙なFizzBuzz - PartialFunctionとimplicit conversionを添えて, 最近のコメント, 2010-03-16 @tkmsm, 2010-06-21 and You, 2010-05-06 fmaction, 2010-05-06 kumonopanya, 2010-05-06 yuroyoro, 最近のトラックバック, 2010-09-14 Twi...