VB.NET으로 웹 페이지를 긁어 올 때 WebBrowser 컨트롤로 DOM을 뒤지거나 정규식으로 잘라 내는 방법도 있지만, HTML 구조가 조금만 복잡해지면 금방 지저분해집니다. 이럴 때 NuGet에서 HtmlAgilityPack을 받아 쓰면 받아온 HTML 문자열을 XPath로 탐색할 수 있어서 코드가 훨씬 간결해집니다. 브라우저 없이 동작하니 속도도 빠르고요.

아래는 쿠팡 검색 결과 페이지를 파싱할 때 쓰던 코드 일부입니다. 완성된 프로그램이 아니라 "어떤 값은 이렇게 꺼낸다"를 보여 주는 조각이라, 변수 T 에 값을 계속 덮어쓰고 있습니다. 실제로 쓸 때는 각 줄의 결과를 DataTable 열이나 클래스 속성에 나눠 담으면 됩니다. T 에는 미리 HTTP 요청으로 받아 둔 페이지 HTML이 들어 있다고 보면 됩니다.

광고
Imports HtmlAgilityPack

        Dim doc As New HtmlDocument, a As HtmlNode, img As HtmlNode, hNode As HtmlNode
        doc.LoadHtml(T)
        If Not doc.DocumentNode.SelectNodes("//*[@id=""productList""]/li") Is Nothing Then
            For Each li As HtmlNode In doc.DocumentNode.SelectNodes("//*[@id=""productList""]/li")
                a = li.SelectSingleNode("a")
                T = a.SelectSingleNode("dl/dd/div[2]").InnerHtml.Replace(vbLf, Space(1)).Trim
                T = a.Attributes("data-item-id").Value
                T = a.Attributes("data-is-rocket").Value = "true"
                T = "https://www.coupang.com" & a.Attributes("href").Value.Replace("&", "&")
                img = a.SelectSingleNode("dl/dt/img")
                T = "https:" & img.Attributes("src").Value
                If Not img.Attributes("data-img-src") Is Nothing Then
                    T = "https:" & img.Attributes("data-img-src").Value
                Else
                    Application.DoEvents()
                End If
                If Not a.SelectSingleNode("//*[@id=""searchOptionForm""]/div/div/div[1]/div/div[1]/h3") Is Nothing Then
                    T = doc.DocumentNode.SelectSingleNode("//*[@id=""searchOptionForm""]/div/div/div[1]/div/div[1]/h3").InnerText.Replace(vbTab, Space(1)).Replace(vbLf, Space(1)).Split("("c).First.Trim
                End If
                T = a.SelectSingleNode("dl/dd/div[3]/div[1]/div[1]/em/strong").InnerText
                hNode = GetClassNode(a.SelectSingleNode("dl/dd/div[3]/div[1]/div[1]/span[1]"), "span", "discount-percentage")
            Next
        End If

    Private Function GetClassNode(ByVal Node As HtmlNode, ByVal tagName As String, ByVal className As String) As HtmlNode
        Try
            Dim ND As List(Of HtmlNode) = Node.Descendants(tagName).Where(Function(k) k.Attributes.Contains("class") AndAlso k.Attributes("class").Value.Contains(className)).ToList
            If ND.Count > 0 Then Return ND.First
        Catch ex As Exception
        End Try
        Return Nothing
    End Function

줄마다 무엇을 꺼내는지

흐름은 단순합니다. id="productList" 인 목록 아래의 li 하나가 상품 하나이고, 그 안의 a 태그를 기준으로 필요한 값을 상대 경로로 찾아갑니다.

dl/dd/div[2] 의 내용은 상품명입니다. 줄바꿈이 섞여 있어서 vbLf 를 공백으로 바꾸고 앞뒤를 잘랐습니다. 상품 고유 번호는 data-item-id 속성, 로켓배송 여부는 data-is-rocket 속성 값이 "true"인지로 판단합니다. 상세 페이지 링크는 href 가 상대 경로라서 도메인을 앞에 붙이고, HTML 속성 안에 & 로 들어 있는 것을 & 로 되돌려야 실제로 열리는 주소가 됩니다.

이미지는 조금 신경 쓸 부분이 있습니다. 쇼핑몰 목록은 대부분 지연 로딩을 써서, 처음에는 src 에 빈 이미지나 작은 자리표시 이미지를 넣고 진짜 주소는 data-img-src 같은 속성에 숨겨 둡니다. 그래서 src 를 먼저 읽고, data-img-src 가 있으면 그 값으로 덮어씁니다. 주소가 // 로 시작하는 프로토콜 상대 경로라서 https: 를 붙여 줬고요. Else 쪽의 Application.DoEvents() 는 실질적인 처리라기보다 비워 둔 자리라고 보면 됩니다.

searchOptionForm 아래 h3 는 검색 결과 상단의 분류명 영역인데, 괄호 앞까지만 잘라서 씁니다. 여기서 알아 둘 점은 XPath가 // 로 시작하면 a.SelectSingleNode 로 불러도 현재 노드가 아니라 문서 전체에서 찾는다는 겁니다. 그래서 상품마다 같은 값이 나옵니다. 상품 노드 안에서만 찾고 싶을 때는 .// 로 시작해야 합니다. HtmlAgilityPack을 처음 쓸 때 가장 많이 헷갈리는 부분이에요.

마지막으로 가격은 em/strong 의 텍스트에서, 할인율은 클래스 이름으로 찾습니다.

클래스명으로 찾는 GetClassNode

XPath로 [@class='discount-percentage'] 라고 쓰면 class 속성 값이 정확히 그 문자열일 때만 맞습니다. 실제 사이트는 class="discount-percentage on" 처럼 여러 클래스를 같이 쓰는 경우가 많아서 잘 안 걸리죠. GetClassNode 는 지정한 노드의 하위 요소 중 태그 이름이 맞고 class 값에 해당 문자열이 포함된 첫 번째 노드를 LINQ로 찾아 돌려줍니다. 못 찾거나 기준 노드가 Nothing이면 예외를 삼키고 Nothing을 반환하므로, 호출한 쪽에서 Is Nothing 검사를 해 줘야 합니다.

Contains 로 비교하기 때문에 price 를 찾으면 base-price, price-info 도 같이 걸린다는 점은 감안해야 합니다. 정확히 맞추려면 class 값을 공백으로 나눈 배열에 해당 이름이 있는지 확인하는 방식으로 바꾸면 됩니다.

돌려 볼 때 주의할 점

T = a.Attributes("data-is-rocket").Value = "true" 줄은 비교 결과(Boolean)를 문자열 변수 T 에 넣는 형태라 Option Strict Off에서만 컴파일됩니다. 실제 코드에서는 Dim isRocket As Boolean = (a.Attributes("data-is-rocket").Value = "true") 처럼 따로 받는 게 맞습니다.

또 하나, 속성이나 노드가 없는 상품이 하나라도 섞이면 .Value 나 .InnerText 에서 NullReferenceException 이 납니다. 광고 상품이나 품절 상품은 구조가 조금씩 다른 경우가 있어서, 운영용 코드라면 노드를 받은 뒤 Nothing 검사를 거치거나 GetAttributeValue("data-item-id", "") 처럼 기본값을 주는 메서드를 쓰는 편이 안전합니다. 쇼핑몰은 HTML 구조를 자주 바꾸고 자동 수집을 막는 장치도 있어서, 위 XPath는 그대로 맞지 않을 수 있다는 점도 감안하세요.