본문 바로가기
카테고리 없음

HTML 문서 파싱하기

by 경자꿈사 2025. 6. 9.

이번 글에서는 HTML 문서를 파싱해서 원하는 데이터를 추출하는 방법을 알아보자.
'주식 가격 조회하기' 글에서는 특정 종목의 현재 주식 가격을 조회하기 위해 먼저, 포털 사이트에 HTTPS 요청을 보내 검색 결과(HTML 문서)를 받아온 후, 
해당 결과에서 주식 가격이 포함된 태그를 정규 표현식을 사용해서 찾았었다.
이처럼 간단한 경우에는 정규 표현식만으로도 내가 원하는 데이터를 찾을 수 있지만, 추출하려는 데이터가 복잡하거나, 데이터 추출뿐 아니라 HTML 문서에 대한 수정 작업도 필요하다면, 
전용 라이브러리를 사용하는 것이 좋다.

아래 코드는 정규 표현식을 사용해 주식 가격을 조회해 오는 기존 코드이다.

require 'cgi'
require 'net/https'

class StockUtil
  def initialize
    @http = Net::HTTP.new("m.search.daum.net", 443)
    @http.use_ssl = true
    @http.verify_mode = OpenSSL::SSL::VERIFY_PEER
  end
  
  def get_price(name)
    query = CGI.escape("#{name} 주가")
    response = @http.get("/search?w=tot&DA=BJE&q=#{query}")

    html = response.body
    md = html.match(/<span class="current_stock">([\d,]+)<\/span>/i)

    if md
      md[1]
    end
  end
end

 

앞의 코드를 정규 표현식 대신 nokogiri를 사용하도록 변경해 보자.
아래 코드를 보면, 다른 부분은 변경이 없고 다만, 응답으로 받은 HTML 문서에서 가격 정보를 찾기 위해 정규 표현식을 사용하던 코드가 CSS Selector("span.current_stock")를 사용하는 코드로 변경되었다.
"span.current_stock"는 class 속성값이 'current_stock'인 span 요소를 찾는 CSS Selector이다.

require 'cgi'
require 'net/https'
require 'nokogiri'

class StockUtil
  def initialize
    @http = Net::HTTP.new("m.search.daum.net", 443)
    @http.use_ssl = true
    @http.verify_mode = OpenSSL::SSL::VERIFY_PEER
  end
  
  def get_price(name)
    query = CGI.escape("#{name} 주가")
    response = @http.get("/search?w=tot&DA=BJE&q=#{query}")

    html = response.body
    doc = Nokogiri::HTML(html)
    span = doc.at("span.current_stock")

    if span
      span.text
    end
  end
end

 

그리고 CSS Selector 대신 XPath를 통해서도 가격 정보를 조회할 수 있다.
('XML 문서 파싱하기'글에서 이미 XPath를 사용한 적이 있다.)

span = doc.at("span[@class='current_stock']")

 

'단어 검색기 만들기' 글에서도 HTML 문서 안에서 필요한 데이터(검색한 단어의 의미)를 추출하기 위해 정규 표현식을 사용했었다.
단어의 뜻을 가져오기 위해 사용한 사이트는 검색 결과 HTML 문서 안에 실제 단어의 의미가 담긴 데이터를 JSON 형식으로 포함시켜 놓았다.
그리고 그 JSON 데이터를 사용하여 웹브라우저에서 자바스크립트를 통해 동적으로 HTML 문서의 내용을 변경하여 해당 내용을 표시한다.
그래서, 웹브라우저에서 개발자 도구 등을 사용해 확인한 HTML 문서의 구조와 우리가 작성한 프로그램에서 HTTPS 요청을 통해 받은 HTML 문서의 구조가 다르게 된다.
결국, 단순히 HTTPS 요청을 통해 받은 HTML 문서에는 (nokogiri 등 어떤 라이브러리를 사용하는지와는 무관하게) 우리가 원하는 요소가 없을 수 있다.
여러 해결 방법이 있을 수 있겠지만, '단어 검색기 만들기' 글에서는 정규 표현식을 사용하여 HTML 문서에 포함된 JSON 데이터에서 원하는 데이터를 추출하였다.

require 'cgi'
require 'net/https'

class Dictionary
  def initialize
    @http = Net::HTTP.new("dict.naver.com", 443)
    @http.use_ssl = true
    @http.verify_mode = OpenSSL::SSL::VERIFY_NONE
  end

  def get_mean(word)
    query = CGI.escape(word)
    response = @http.get("/dict.search?query=#{query}")
    html = response.body

    md = html.match(/meanList:\[(.+?)}\]/)
    if md
      md[1].scan(/mean:"(.+?)"/).flatten
    end
  end
end

 

그런데, 응답 HTML 문서에 JSON 데이터를 포함시키지 않고, 별도의 AJAX 요청으로 JSON 데이터를 받아와 화면 표시에 사용하는 경우도 있다.
예를 들어, 웹브라우저로 영풍문고 사이트에서 베스트셀러 메뉴를 들어가 보면, 베스트셀러 목록을 볼 수 있는데, 
아래처럼 해당 URL로 요청을 보내 받은 응답 데이터에는 베스트셀러에 대한 데이터가 없다.

>> require 'net/https'

>> http = Net::HTTP.new("www.ypbooks.co.kr", 443)
>> http.use_ssl = true
>> http.verify_mode = OpenSSL::SSL::VERIFY_PEER

>> response = http.get("/bestseller/week")
=> #<Net::HTTPOK 200 OK readbody=true>
>> html = response.body
>> puts html.encoding
ASCII-8BIT

>> html.force_encoding("UTF-8")

>> html.match(/모순/)
=> nil

 

이럴 경우, 베스트셀러에 대한 데이터를 가져오는 방법 중 하나는, 베스트셀러에 대한 데이터를 직접 요청해서 받는 것이다.
아래 그림처럼 웹브라우저(크롬 기준)의 개발자 도구에서 Network 메뉴를 선택하고, 다시 아래 Fetch/XHR을 선택하면 AJAX 요청/응답만 필터링해서 볼 수 있는데, 
영풍문고 사이트의 주간 베스트셀러에서 '국내 소설'을 클릭하면 요청이 발생하여 새로운 로그가 표시된다.
만약, 새로운 로그가 표시되지 않는다면, 웹브라우저에서 Ctrl + F5를 눌러 새로고침을 한 후, 다시 '국내 소설'을 클릭하자.
기존의 다른 로그들 때문에 보기 어렵다면, 개발자 도구에서 네트워크 로그를 먼저 삭제한 다음 시도해 보자.

 

로그를 클릭하면 아래 그림처럼 해당 요청과 응답에 대한 데이터를 볼 수 있다.
그중 Headers 탭을 보면 Request URL 정보를 볼 수 있고, Response 탭을 보면 우리가 원하는 베스트셀러 데이터를 볼 수 있다.

 

이제 베스트셀러 데이터를 얻을 수 있는 URL을 알았으니, 프로그램에서 해당 URL로 요청을 보내 베스트셀러 데이터를 가져와 보자.

>> require 'net/https'

>> http = Net::HTTP.new("www.ypbooks.co.kr", 443)
>> http.use_ssl = true
>> http.verify_mode = OpenSSL::SSL::VERIFY_PEER

>> response = http.get("/back_shop/base_shop/api/v1/best-seller/bestCd/1/10?year=2025&month=5&week=4&searchDiv=W&outOfStock=y&categoryIdKey=&categoryBestCd=A002")
=> #<Net::HTTPOK 200 OK readbody=true>
>> html = response.body

>> puts html
{
  "data":{
     "dataList":[
        {
         "pubDate":"2013-04-01 00:00:00",
         "bookProductInfo":{"bookTitle":"모순", "pubCompanyName":"쓰다", ...}, 
         ...
        }, 
        ...
     ]
  }
}

 

아래는 베스트셀러 데이터를 JSON 라이브러리로 파싱해서 몇 가지 데이터만 추출하여 화면에 출력해 보았다.
(JSON과 관련해서 좀 더 자세히 알고 싶으면 'JSON 다루기'글을 참고하기 바란다.)

>> require 'net/https'
>> require 'json'

>> http = Net::HTTP.new("www.ypbooks.co.kr", 443)
>> http.use_ssl = true
>> http.verify_mode = OpenSSL::SSL::VERIFY_PEER

>> response = http.get("/back_shop/base_shop/api/v1/best-seller/bestCd/1/10?year=2025&month=5&week=4&searchDiv=W&outOfStock=y&categoryIdKey=&categoryBestCd=A002")
=> #<Net::HTTPOK 200 OK readbody=true>
>> html = response.body

?> JSON.parse(html)["data"]["dataList"].each do |data|
?>   info = data["bookProductInfo"]
?>   puts "[#{data["bestSellerCategoryNm"]}] #{info["bookTitle"]}"
?>   puts "#{info["sapWriterName"]} | #{info["pubCompanyName"]} | #{data["pubDate"][0, 7]}"
?>   puts
>> end
[국내소설] 모순
양귀자 | 쓰다 | 2013-04

[국내소설] 급류(오늘의 젊은 작가40)
정대건 | 민음사 | 2022-12

[국내소설] 소년이 온다
한강 | 창비 | 2024-05

... 생략

 

CSS Selector나 XPath에 대해서는 이후 필요할 때마다 실제 코드와 함께 설명을 하도록 하겠다.

See you again~~