스크래핑

HTML 파싱은 그만: JSON-LD와 임베디드 데이터에서 구조화된 데이터 추출하기

대부분의 페이지에는 이미 기계가 읽을 수 있는 데이터가 포함되어 있습니다. 취약한 셀렉터 대신 JSON-LD와 임베디드 JSON을 추출하는 방법과 데이터가 불완전할 때 대처하는 방법을 소개합니다.

Matt Brown

Matt Brown

2026년 9월 26일 · 8 분 소요

대부분의 스크레이퍼는 같은 방식으로 만들어진다. 페이지를 열고, 가격이 담긴 요소를 찾고, CSS 선택자를 작성하고, 모든 필드에 대해 이를 반복한다. 사이트가 리디자인되거나, 클래스 이름이 바뀌거나, 가격이 새로운 컴포넌트로 감싸지기 전까지는 잘 작동한다. 그러면 선택자는 아무것도 반환하지 않거나, 더 나쁘게는 잘못된 값을 반환하는데, 파이프라인은 계속 돌아간다.

많은 페이지는 이미 같은 데이터를 기계가 읽을 수 있는 형태로도 게시하고 있다. 검색 엔진이 이를 요청했고, 사이트는 이를 제공했으며, 그것은 줄곧 페이지 소스 안에 있었다. 이 튜토리얼은 그것을 찾는 방법, 몇십 줄의 코드로 추출하는 방법, 그리고 아래의 실제 사례가 보여주듯 문서에서 시사하는 것보다 훨씬 자주 발생하는, 데이터가 누락되거나 불완전한 경우를 다루는 방법을 보여준다.

핵심 요약

  • JSON-LD는 2024년 Web Almanac에서 전체 페이지의 41%에 나타났으며, 이는 2022년의 34%에서 증가한 수치다. 제품, 기사, 이벤트, 조직 정보에 있어 이는 종종 페이지에서 가장 안정적인 소스다.
  • 구조화 데이터는 페이지 레이아웃보다 훨씬 덜 자주 바뀌는데, 사이트가 검색 결과를 위해 이에 의존하기 때문이다. 선택자는 리디자인 때 깨지지만, 구조화 데이터는 대개 그것을 견뎌낸다.
  • 항상 완전한 것은 아니다. 제품 페이지가 화면에 표시된 변형(variant)에 대해서는 완전한 레코드를 게시하면서, 다른 모든 변형에 대해서는 단순 링크만 게시할 수도 있다. 모든 레코드를 검증하라.
  • 가장 견고한 추출기는 구조화 데이터를 먼저 시도하고, 그 다음 임베디드 JSON을, 마지막으로 CSS 선택자를 시도하며, 어느 것을 사용했는지 기록한다.

웹 페이지에서 “구조화 데이터”가 의미하는 것

기계가 읽을 수 있는 데이터가 흔히 존재하는 곳은 네 군데다.

소스형태일반적인 내용
JSON-LD<script type="application/ld+json"> 블록schema.org 객체: Product, Offer, NewsArticle, Organization, Event, BreadcrumbList
Microdata보이는 요소에 붙은 itemprop 속성마크업 전체에 흩어진 동일한 schema.org 어휘
Open Graph와 메타 태그<meta property="og:...">제목, 설명, 이미지, 때로는 가격
임베디드 애플리케이션 상태페이지의 JavaScript가 읽어들이는 큰 JSON 객체, 예를 들어 __NEXT_DATA__종종 페이지가 표시하는 모든 것, 그 이상까지

HTTP Archive의 2024년 Web Almanac은 웹 전반에서 이들의 분포를 측정했다. JSON-LD는 “2022년의 34%에서 2024년에는 41%로” 증가했고, microdata는 26%로 유지되었으며, 대부분의 사이트가 추가하는 소셜 공유 태그를 포함하는 RDFa와 Open Graph는 각각 페이지의 66%와 64%에 나타났다. JSON-LD가 가장 먼저 시도해볼 만한 것인데, 레이아웃 전반에 흩어진 속성이 아니라 독립적인 데이터 블록이기 때문이다.

선택자보다 신뢰할 수 있는 이유

CSS 선택자는 페이지가 어떻게 보이는지에 의존한다. 구조화 데이터는 페이지가 무엇을 의미하는지에 의존한다. 사이트는 페이지가 보이는 방식을 끊임없이 바꾼다. 반면 구조화 데이터가 말하는 내용은 훨씬 덜 자주 바꾸는데, 이는 검색 엔진의 리치 결과에 쓰이며 이를 망가뜨리면 사이트에 눈에 띄는 비용이 발생하기 때문이다.

또한 더 정직하게 실패한다. 매칭을 멈춘 선택자는 조용히 다른 요소를 매칭해 그럴듯하지만 잘못된 값을 반환할 수 있는데, 이는 침묵하는 실패율에서 설명한 종류의 오류다. JSON-LD 객체는 price 필드를 포함하거나 포함하지 않거나 둘 중 하나이므로, 검증이 단순해진다.

Python으로 JSON-LD 추출하기

표준 라이브러리만으로 충분하다. 이 추출기는 모든 JSON-LD 블록을 수집하고, 깨진 블록도 허용하며, 사이트가 객체를 중첩시키는 데 쓰는 컨테이너들, 즉 리스트, @graph, 그리고 schema.org가 제품 변형에 사용하는 hasVariant를 따라간다.

import json
from html.parser import HTMLParser


class _Collector(HTMLParser):
    """Collect JSON-LD blocks and embedded JSON state from an HTML page."""

    def __init__(self):
        super().__init__()
        self.blocks, self._buf, self._kind = [], None, None

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag == "script" and a.get("type", "").lower() == "application/ld+json":
            self._buf, self._kind = [], "json-ld"
        elif tag == "script" and a.get("id") == "__NEXT_DATA__":
            self._buf, self._kind = [], "next-data"

    def handle_data(self, data):
        if self._buf is not None:
            self._buf.append(data)

    def handle_endtag(self, tag):
        if tag == "script" and self._buf is not None:
            raw = "".join(self._buf).strip()
            try:
                self.blocks.append((self._kind, json.loads(raw)))
            except json.JSONDecodeError:
                self.blocks.append((self._kind + "-invalid", raw[:200]))
            self._buf = self._kind = None


def _walk(node):
    """Yield every JSON-LD object, flattening lists and nested containers."""
    if isinstance(node, list):
        for item in node:
            yield from _walk(item)
    elif isinstance(node, dict):
        yield node
        for key in ("@graph", "mainEntity", "itemListElement", "hasVariant"):
            if key in node:
                yield from _walk(node[key])


def _types(obj):
    t = obj.get("@type", [])
    return {t} if isinstance(t, str) else set(t)


def jsonld_objects(html, wanted_type=None):
    """Every JSON-LD object on the page, optionally filtered by schema.org type."""
    collector = _Collector()
    collector.feed(html)
    objs = [o for kind, data in collector.blocks if kind == "json-ld" for o in _walk(data)]
    return [o for o in objs if wanted_type is None or wanted_type in _types(o)]

실제 Guardian 기사에서 jsonld_objects(html, "NewsArticle")는 헤드라인, 발행 및 수정 타임스탬프, 저자를 선택자 없이 반환한다. 그 타임스탬프만으로도 노력할 가치가 있는데, 사이트의 모든 기사에서 정확하고 기계가 읽을 수 있으며 일관되기 때문이다.

제품 정규화

가격이 중첩된 offers 안에 들어 있고, 때로는 단일 Offer로, 때로는 가격 범위를 가진 AggregateOffer로 존재하기 때문에 제품은 좀 더 세심하게 다뤄야 한다.

def products(html):
    """Return normalised product records found in a page's JSON-LD."""
    out = []
    for obj in jsonld_objects(html, "Product"):
        offers = obj.get("offers") or {}
        offer = offers[0] if isinstance(offers, list) and offers else offers
        if isinstance(offer, dict) and "AggregateOffer" in _types(offer):
            price = offer.get("lowPrice")
        else:
            price = offer.get("price") if isinstance(offer, dict) else None
        brand = obj.get("brand")
        out.append({
            "name": obj.get("name"),
            "sku": obj.get("sku") or obj.get("gtin13") or obj.get("mpn"),
            "brand": brand.get("name") if isinstance(brand, dict) else brand,
            "price": float(price) if price not in (None, "") else None,
            "currency": offer.get("priceCurrency") if isinstance(offer, dict) else None,
            "availability": (offer.get("availability") or "").rsplit("/", 1)[-1] if isinstance(offer, dict) else None,
        })
    return out

표준적인 product와 offer가 있는 페이지에 대해 실행하면, 페이지가 어떻게 스타일링되었는지와 무관하게 {"name": "Trail Runner", "sku": "TR-01", "brand": "Acme", "price": 89.0, "currency": "EUR", "availability": "InStock"} 같은 깔끔한 레코드를 반환한다.

구조화 데이터가 불완전할 때: 실제 사례

문서의 예시들은 이것을 쉬워 보이게 만든다. 실제 페이지는 더 지저분하며, 하나를 보여줄 가치가 있다.

우리는 큰 Shopify 스토어의 인기 신발 제품 페이지에 대해 추출기를 실행했다. 페이지의 JSON-LD는 변형으로 판매되는 제품을 위한 schema.org 타입인 ProductGroup을 기술했으며, 제품명, 브랜드, 설명, 이미지, 그리고 49개의 변형을 담고 있었다. 그중 화면에 표시된 색상의 사이즈에 해당하는 7개만이 $100.00의 가격과 재고 상태를 가진 완전한 제품이었다. 다른 42개, 즉 나머지 모든 색상과 사이즈는 타입과 URL만 있을 뿐 아무것도 없는 단순 참조였다. 리뷰 평점은 별도의 두 번째 JSON-LD 블록에 있었다.

즉 구조화 데이터는 카탈로그가 아니라 페이지를 기술한 것이었다. “모든 변형이 JSON-LD에 있다”고 가정한 파이프라인이라면, 조용히 한 가지 색상에만 가격을 매기고 나머지에 대해서는 아무것도 기록하지 못했을 것이다.

같은 스토어는 또한 각 제품의 공개 JSON 표현도 제공하는데, 이는 일치했다. 해당 색상에 대한 7개의 변형 각각에 가격과 재고 플래그가 있었다. 하지만 거기서는 가격이 소단위(minor units)로, 즉 10000으로 표시되어 있었다. 두 소스를 정규화 없이 섞은 파이프라인이라면, 같은 신발을 한 곳에서는 $100로, 다른 곳에서는 만 달러로 기록했을 것이다.

세 가지 교훈이 뒤따르며, 이는 Shopify를 훨씬 넘어서 적용된다.

  • 검증하라, 가정하지 마라. 파싱되는 JSON-LD 블록이 완전한 레코드라는 의미는 아니다. 필요한 모든 필드가 존재하는지 확인하고, 얻은 것을 기대한 것과 대조해 개수를 세라.
  • 구조화 데이터가 부분적일 때는 참조를 따라가라. 변형 URL, 임베디드 JSON, 공개 제품 엔드포인트가 종종 빈틈을 채워주며, 대개 페이지 자체보다 더 깔끔하다.
  • 단위를 명시적으로 정규화하라. 소단위, 가격 범위, 세금 포함 및 세금 제외 가격, 통화는 모두 가정이 아니라 코드로 처리해야 한다.

소스를 기록하는 폴백 체인

각 요소를 체인으로 결합하라. 구조화 데이터를 먼저 시도하고, 그 다음 임베디드 JSON을, 그 다음 선택자를 시도하며, 각 레코드를 만들어낸 소스를 기록해 두라.

REQUIRED = ("name", "price", "currency")


def extract_product(html, embedded=None, css_fallback=None):
    for source, candidates in (
        ("json-ld", products(html)),
        ("embedded-json", embedded(html) if embedded else []),
        ("css", css_fallback(html) if css_fallback else []),
    ):
        for record in candidates:
            if all(record.get(f) not in (None, "") for f in REQUIRED):
                return {**record, "source": source}
    return None

source 필드는 그 값을 빠르게 증명한다. 항상 json-ld 레코드를 만들어내던 사이트가 css 레코드를 만들어내기 시작하면, 그 사이트의 구조화 데이터가 바뀌었거나 사라진 것이며, 선택자 폴백까지 깨지기 전에 이를 알고 싶을 것이다. 이는 또한 타겟 헬스 스코어에 유용한 입력값이기도 하다.

Web Scraping API로 처리하기

Shifter의 Web Scraping API를 통해 페이지를 가져온다면, 직접 브라우저를 실행하지 않고도 같은 접근 방식이 작동한다. API의 extract_rules 파라미터는 CSS 선택자를 JSON 필드에 매핑하며, html 출력은 요소의 내부 HTML을 반환하므로, JSON-LD 스크립트를 선택하는 규칙은 파싱할 원시 블록을 그대로 반환해준다.

{
  "jsonld": { "selector": "script[type='application/ld+json']", "output": "html" }
}

단일 규칙은 처음 매칭되는 요소만 반환하므로, JSON-LD 블록이 여러 개인 페이지의 경우 전체 HTML을 요청해 위의 추출기를 그 위에서 실행하라. JavaScript로 구조화 데이터를 주입하는 페이지에는 render_js=1을 함께 사용하고, 제품 JSON URL 같은 JSON 엔드포인트를 직접 가져올 때는 auto_parser=1을 사용해 파싱된 본문을 받아라. 누락된 필드는 요청을 실패시키는 대신 null로 돌아오므로, 위에서 설명한 검증 후 폴백 패턴에 맞아떨어진다. 전체 문법은 추출 규칙 문서에 있다.

구조화 데이터가 주지 않는 것

구조화 데이터는 사이트가 검색 엔진을 위해 게시하기로 선택한 내용을 기술한다. 보이는 페이지보다 뒤처지거나, 사이트가 굳이 노출하지 않는 필드를 생략하거나, 화면의 변형이 아닌 기본 변형을 기술할 수도 있다. 특히 가격의 경우, 표본을 기준으로 이를 눈에 보이는 페이지와 비교해야 하는데, 오래된 JSON-LD 가격과 현재의 페이지상 가격이 서로 다른 관점에서는 둘 다 “맞는” 값이기 때문이다. 그리고 방문자 위치에 따라 콘텐츠가 달라지는 사이트의 경우 구조화 데이터도 달라지므로, 항공권 및 호텔 가격 스크레이핑에서 다룬 것처럼 관심 있는 시장에서 이를 수집해야 한다.

결론

또 다른 선택자를 작성하기 전에, 페이지 소스를 열고 application/ld+json을 검색해보라. 웹의 상당 부분에서 원하는 데이터는 이미 그곳에 공유된 어휘로 라벨링되어 있으며, 주변 레이아웃보다 바뀔 가능성이 훨씬 낮다.

먼저 그것을 추출하고, 검증하고, 불완전할 경우 임베디드 JSON으로, 그다음 선택자로 폴백하며, 각 레코드가 어느 소스에서 왔는지 기록하라. 그러면 추출기는 덜 자주 깨질 것이고, 깨지더라도 그 사실을 알려줄 것이다.

출처 및 참고자료

시작할 준비가 되셨나요?

205M개 이상의 IP, 195개 이상의 국가를 지원하는 Shifter의 레지덴셜 프록시를 $0.10/GB부터 이용해보세요.

시작하기