본문 바로가기
반응형
Programming/Scala

[Scala/Spark] XML 파싱 에러 (SAXParseException) 해결하고 HTML 파싱해보기

by JAMINS 2020. 5. 28.

Scala로 HTML형태의 XML 파일을 파싱하는 작업을 하고 있었다. 정확히는 Spark를 활용하여 XML Raw data를 파싱하여 값을 추출하는 작업이다. XML의 attribute값 또는 value를 추출하여 DataFrame으로 변환해야되므로 반드시 파싱을 해야만 했다. 일반적인 XML 형태라면 Databricks의 spark-xml 모듈을 사용하여 손쉽게 DataFrame으로 변환하면 되지만 다루는 데이터는 일반적이지 않은 XML이다.

문제를 일으키는 XML파일은 JATS (Journal Article Tag Suite) 형식이다. JATS는 논문이나 저널의 메타데이터를 공통된 포맷을 제공한다. 이 형식은 어느정도 정형화된 포맷을 유지하지만 일부 HTML Entity가 섞여있기 때문에 평범하게 XML 파싱하기란 쉽지 않았다.

import scala.xml.XML
import java.io.File

val xmlElem = XML.loadString("...")
val xmlElem2 = XML.loadFile(new File("..."))

Scala에서 XML을 파싱할 때는 위와 같이 문자열이나 파일을 입력받아 Element로 변환한다. 이 때 SAXParser를 내부적으로 사용하는데 보통은 Element 결과를 받아서 값을 추출한다. 이번에도 이 방식으로 변환하려 했으나 아래와 같이 문제가 발생했다.

Scala XML 파싱할 때 SAXParseException 발생

org.xml.sax.SAXParseException: The entity "copy" was referenced, but not declared.
  at org.apache.xerces.util.ErrorHandlerWrapper.createSAXParseException(Unknown Source)
  at org.apache.xerces.util.ErrorHandlerWrapper.fatalError(Unknown Source)
  at org.apache.xerces.impl.XMLErrorReporter.reportError(Unknown Source)
  at org.apache.xerces.impl.XMLErrorReporter.reportError(Unknown Source)
  at org.apache.xerces.impl.XMLErrorReporter.reportError(Unknown Source)
  at org.apache.xerces.impl.XMLScanner.reportFatalError(Unknown Source)
  at org.apache.xerces.impl.XMLDocumentFragmentScannerImpl.scanEntityReference(Unknown Source)
  at org.apache.xerces.impl.XMLDocumentFragmentScannerImpl$FragmentContentDispatcher.dispatch(Unknown Source)
  at org.apache.xerces.impl.XMLDocumentFragmentScannerImpl.scanDocument(Unknown Source)
  at org.apache.xerces.parsers.XML11Configuration.parse(Unknown Source)
  at org.apache.xerces.parsers.XML11Configuration.parse(Unknown Source)
  at org.apache.xerces.parsers.XMLParser.parse(Unknown Source)
  at org.apache.xerces.parsers.AbstractSAXParser.parse(Unknown Source)
  at org.apache.xerces.jaxp.SAXParserImpl$JAXPSAXParser.parse(Unknown Source)
  at org.apache.xerces.jaxp.SAXParserImpl.parse(Unknown Source)
  at scala.xml.factory.XMLLoader$class.loadXML(XMLLoader.scala:41)
  at scala.xml.XML$.loadXML(XML.scala:60)
  at scala.xml.factory.XMLLoader$class.loadFile(XMLLoader.scala:48)
  at scala.xml.XML$.loadFile(XML.scala:60)
  ... 49 elided

Exception Trace이다. copy 라는 entity가 참조되었지만 정의되지 않았다라.. HTML entity 중 하나인 copy entity가 정의되지 않아서 생긴 파싱 오류였다. 원본 파일을 살펴보니 역시 저 entity가 포함되어 있었다.

<?xml version="1.0" encoding="UTF-8"?>
<article xmlns:xlink="http://www.w3.org/1999/xlink" article-type="research" dtd-version="1.1" specific-use="production" xml:lang="en">
   <front>
     <journal-meta>
         <journal-id journal-id-type="publisher">Psychosomatic Medicine and General Practice</journal-id>
         <issn>2519-8572</issn>
                 <journal-title-group>
                        <journal-title>Psychosomatic Medicine and General Practice</journal-title>
                 </journal-title-group>
         <publisher>
            <publisher-name>Private Publisher 'Chaban O. S.'</publisher-name>
         </publisher>
     </journal-meta>
     <article-meta>
         .....
        <permissions>
            <copyright-statement>#&copy; 2017, Kovalenko O., Chizhukova M.</copyright-statement>
            <copyright-year>2018</copyright-year>
            <copyright-holder>Kovalenko O., Chizhukova M.</copyright-holder>
       </permissions>
     </article-meta>

원본을 보아하니 permissions - copyright 부분에 #&copy; 문자가 보였다. 이는 HTML Entity중 하나인데 일반적으로 XML파싱을 할 수는 없고 Entity 문자들을 replace하거나 HTML 파싱을 해야 해결이 가능하다.

해결방안

1. Replace

엔티티로 정의되어있는 문자들을 그냥 원래 문자로 replace하는 방법이다. Entity의 요소를 알 수 있으니 노다가스럽겠지만 대응이 가능하다.

import scala.xml.XML
import scala.io.Source
import java.io.File

val xmlPath = "~/test.xml"

// 파일 읽어서 String화
val raw = Source.fromFile(new File(xmlPath)).getLines.mkString

// entity 문자열 replace
val xmlString = raw.replace("#&copy;", "©")]

val xmlElem = XML.loadString(xmlString)

문제되는 copy entity에만 적용했지만 위와 같이 변환해서 사용하면 된다.

2. Scala html parsing

HTML형태를 파싱하는 방법이 있다. Scala의 library중 HTML parser를 받아서 XML 오브젝트와 같이 구현체를 만들면 된다. 일단 sbt에 해당 라이브러리를 받기 위해 의존성을 추가하자.

libraryDependencies ++= Seq(
    ...
    "graphframes" % "graphframes" % "0.7.0-spark2.4-s_2.11"
)

추가한 후, 직접 HTML 파서 클래스를 구현하면 된다.

import org.xml.sax.InputSource
import scala.xml._
import parsing._

class HTML5Parser extends NoBindingFactoryAdapter {
  override def loadXML(source : InputSource, _p: SAXParser) = {
    loadXML(source)
  }

  def loadXML(source : InputSource) = {
    import nu.validator.htmlparser.{sax,common}
    import sax.HtmlParser
    import common.XmlViolationPolicy

    val reader = new HtmlParser
    reader.setXmlPolicy(XmlViolationPolicy.ALLOW)
    reader.setContentHandler(this)
    reader.parse(source)
    rootElem
  }
}

위와 같이 클래스를 만들어주고 loadXML을 통해 입력값을 받으면 된다. 이 방식은 XML 오브젝트의 일부를 상속받아 재구현했다. 여기에 내가 사용할 클래스에 맞게 수정을 하겠다.

import org.xml.sax.InputSource
import scala.xml.parsing.NoBindingFactoryAdapter
import scala.xml.{SAXParser, Source}

class JatsXML extends NoBindingFactoryAdapter {
  override def loadXML(source: InputSource, parser: SAXParser) = {
    loadXML(source)
  }

  def loadXML(source: InputSource) = {
    import nu.validator.htmlparser.{common, sax}
    import common.XmlViolationPolicy
    import sax.HtmlParser

    val reader = new HtmlParser
    reader.setXmlPolicy(XmlViolationPolicy.ALLOW)
    reader.setContentHandler(this)
    reader.parse(source)

    // Root Element 조정
    (rootElem \ "body" \ "article").head
  }

  // 문자열을 입력받을 수 있도록 추가
  override def loadString(string: String) = {
    loadXML(Source.fromString(string))
  }
}

두 가지의 요소를 추가했다.

  • String 입력 가능
  • Root Element의 조정

원래 XML string 값으로 데이터 처리를 하려고 했으니 메서드를 추가했고 Root element를 조정했다. rootElem을 조정한 이유는 html 태그가 포함된 구조로 되어있기 때문이다. article 태그로 시작하는 XML인데도 html, body 상위 태그가 존재하여 article 태그를 리턴하도록 했다. (article 태그는 JATS의 기본 태그)

3. StringEscapeUtils 활용

import org.apache.commons.lang3

val unescapeText = StringEscapeUtils.unescapeHtml4(xmlString)
val xmlElem = XML.loadString(unescapeText)

Html Entity 디코딩을 포함한 html 문자열 unescape 함수를 사용한다. apache common 라이브러리를 받아서 사용한다. 내가 직면했던 문제를 해결하는데는 이 방법이 가장 Best.

Scala로 Html 파싱

val xmlPath = "~/test.xml"
val rawString = Source.fromFile(new File(xmlPath)).getLines.mkString

val htmlParser = new JatsXML()
val xmlElem = htmlParser.loadString(rawString)
val copyrightStatement = (xmlElem \\ "permissions" \ "copyright-statement").text
println(copyrightStatement)

// print
© 2017, Kovalenko O., Chizhukova M.

잘 파싱되어 Copy entity 변환된 문자열이 나오는 것을 확인할 수 있다. 하지만 여기도 단점이 존재한다. XmlViolationPolicy.ALLOW 때문인지 단독으로 닫히는 태그 (<issn type="a" />) 부분의 파싱이 제대로 안된 것을 확인했다. 추가로 더 해결해야 할 부분이다.

참고

댓글