Feed Rescue: Raw Ulta Scrapes를 Google Merchant Center XML로 변환

발행: (2026년 2월 28일 오전 11:45 GMT+9)
9 분 소요
원문: Dev.to

Source: Dev.to

번역할 텍스트가 제공되지 않았습니다. 번역이 필요한 본문을 알려주시면 도와드리겠습니다.

Phase 1 – 원본 데이터 분석

XML을 생성하기 전에 원시 데이터를 이해해야 합니다.
Ulta.com‑Scrapers 저장소(셀레니움 및 Playwright 버전)는 각 라인이 동일한 ScrapedData 데이터클래스를 따르는 JSONL 파일을 출력합니다.

전형적인 원시 레코드:

{
  "productId": "2583561",
  "name": "CeraVe - Hydrating Facial Cleanser",
  "brand": "CeraVe",
  "price": 17.99,
  "currency": "USD",
  "availability": "in_stock",
  "description": "Hydrating Facial Cleanser gently removes dirt...",
  "images": [
    {
      "url": "https://media.ulta.com/i/ulta/2583561?w=2000&h=2000",
      "altText": "Product Image"
    }
  ],
  "url": "https://www.ulta.com/p/hydrating-facial-cleanser-pimprod2001719"
}

왜 JSONL인가?
라인 구분 형식이기 때문에 전체(잠재적으로 500 MB) 데이터셋을 메모리에 로드하지 않고도 파일을 제품별로 스트리밍할 수 있습니다.

2단계 – Google Merchant Center 사양

Google Shopping은 g: 네임스페이스를 사용하는 RSS 2.0(또는 Atom) 피드만 허용합니다. 아래는 Ulta 스크래퍼 필드와 GMC XML 태그 간의 필수 매핑입니다.

Ulta 스크래퍼 필드GMC XML 태그요구 사항 / 비고
productIdg:id고유 식별자
nameg:title최대 150자
descriptiong:description일반 텍스트, 깨진 HTML 없음
urlg:link절대 URL
images[0]['url']g:image_link고해상도 기본 이미지
price + currencyg:price형식: 17.99 USD
availabilityg:availabilityin_stock, out_of_stock, preorder 중 하나여야 함
brandg:brand선택 사항이지만 권장
(static)g:condition모든 제품에 대해 new 로 설정

Phase 3 – 필드 매핑 및 변환 로직

1. Price Normalization

def format_gmc_price(amount: float, currency: str) -> str:
    """
    Return a price string in the format required by Google Merchant Center:
    e.g. 17.99 USD
    """
    return f"{amount:.2f} {currency}"

소수점 두 자리 정밀도를 보장하고 ISO‑4217 통화 코드를 추가합니다.

2. Availability Mapping

스크래퍼는 이미 in_stock, out_of_stock, 또는 preorder를 반환합니다.
안전을 위해 예상치 못한 값은 모두 out_of_stock으로 매핑합니다:

def map_availability(value: str) -> str:
    allowed = {"in_stock", "out_of_stock", "preorder"}
    return value if value in allowed else "out_of_stock"

3. Image Handling

Google은 기본 이미지 하나(g:image_link)와 최대 10개의 추가 이미지(g:additional_image_link)를 요구합니다.
리스트의 첫 번째 이미지를 기본 링크로 사용하고, 다음 아홉 개(있는 경우)를 추가 링크로 사용합니다.

def split_images(images: list) -> tuple[str | None, list[str]]:
    """
    Returns (primary_image_url, list_of_additional_image_urls)
    """
    if not images:
        return None, []
    primary = images[0].get("url")
    additional = [img.get("url") for img in images[1:11] if img.get("url")]
    return primary, additional

Phase 4 – 변환기 스크립트 구축

파이프라인은 JSONL 파일을 스트리밍하고 각 레코드를 변환한 뒤 xml.etree.ElementTree를 사용하여 pretty‑printed XML 피드를 작성합니다.

import json
import xml.etree.ElementTree as ET
from xml.dom import minidom

# ----------------------------------------------------------------------
# Helper functions (price, availability, image handling)
# ----------------------------------------------------------------------
def format_gmc_price(amount, currency):
    return f"{float(amount):.2f} {currency}"

def map_availability(value):
    allowed = {"in_stock", "out_of_stock", "preorder"}
    return value if value in allowed else "out_of_stock"

def split_images(images):
    if not images:
        return None, []
    primary = images[0].get("url")
    additional = [img.get("url") for img in images[1:11] if img.get("url")]
    return primary, additional

# ----------------------------------------------------------------------
def create_gmc_feed(input_jsonl: str, output_xml: str) -> None:
    """Read a JSONL file line‑by‑line and write a Google Merchant Center RSS feed."""
    # Namespace registration
    g_ns = "http://base.google.com/ns/1.0"
    ET.register_namespace('g', g_ns)

    # Root element
    rss = ET.Element("rss", version="2.0")
    channel = ET.SubElement(rss, "channel")
    ET.SubElement(channel, "title").text = "Ulta Product Feed"
    ET.SubElement(channel, "link").text = "https://www.ulta.com"
    ET.SubElement(channel, "description").text = "Daily product updates from Ulta"

    # Stream the JSONL file
    with open(input_jsonl, "r", encoding="utf-8") as f:
        for line in f:
            if not line.strip():
                continue  # skip empty lines
            product = json.loads(line)

            # Container
            item = ET.SubElement(channel, "item")

            # Basic required fields
            ET.SubElement(item, f"{{{g_ns}}}id").text = str(product.get("productId"))
            ET.SubElement(item, f"{{{g_ns}}}title").text = product.get("name", "")[:150]
            ET.SubElement(item, f"{{{g_ns}}}description").text = product.get("description", "")
            ET.SubElement(item, f"{{{g_ns}}}link").text = product.get("url")
            ET.SubElement(item, f"{{{g_ns}}}brand").text = product.get("brand")
            ET.SubElement(item, f"{{{g_ns}}}condition").text = "new"

            # Price
            price = product.get("price")
            currency = product.get("currency", "USD")
            if price is not None:
                ET.SubElement(item, f"{{{g_ns}}}price").text = format_gmc_price(price, currency)

            # Availability
            ET.SubElement(item, f"{{{g_ns}}}availability").text = map_availability(
                product.get("availability", "out_of_stock")
            )

            # Images
            primary_img, additional_imgs = split_images(product.get("images", []))
            if primary_img:
                ET.SubElement(item, f"{{{g_ns}}}image_link").text = primary_img
            for img_url in additional_imgs:
                ET.SubElement(item, f"{{{g_ns}}}additional_image_link").text = img_url

    # Serialize and pretty‑print
    raw_xml = ET.tostring(rss, encoding="utf-8")
    pretty_xml = minidom.parseString(raw_xml).toprettyxml(indent="  ")

    # Write to file
    with open(output_xml, "w", encoding="utf-8") as out_f:
        out_f.write(pretty_xml)

# ----------------------------------------------------------------------
# Example usage
# ----------------------------------------------------------------------
if __name__ == "__main__":
    create_gmc_feed("ulta_products.jsonl", "ulta_gmc_feed.xml")

스크립트가 수행하는 작업

  1. Google에서 요구하는 g: 네임스페이스를 등록합니다.
  2. 입력 JSONL 파일을 라인 단위로 스트리밍하여(상수 메모리 사용) 처리합니다.
  3. Phase 2의 표에 따라 각 필드를 매핑하고, 가격, 재고 상태, 이미지에 대한 헬퍼 함수를 적용합니다.
  4. 깔끔하게 들여쓰기된 XML 파일을 작성합니다.

ulta_gmc_feed.xml 파일이 Google Merchant Center에 업로드할 준비가 되었습니다.

Phase 5 – 엣지 케이스 처리

웹 데이터는 지저분합니다. Ulta 스크래핑을 처리할 때 흔히 마주치는 세 가지 문제는 다음과 같습니다:

  1. 설명에 포함된 HTML – Ulta의 설명에는 때때로  와 같은 원시 HTML 태그나 엔티티가 포함됩니다. 스크래퍼가 대부분을 정리하지만, 설명을 CDATA 섹션에 감싸거나 남아 있는 태그를 제거하기 위해 정규식(regex)을 사용하는 것이 더 안전합니다.
  2. 절대 URL – 리포지토리의 make_absolute_url 로직을 사용하도록 스크래퍼를 설정하세요. Google은 /p/product-name과 같은 상대 URL을 거부합니다.
  3. 가격이 0이거나 누락된 경우 – 가끔 제품에 “Price Varies” 또는 “Out of Stock”와 같이 숫자 값이 없는 경우가 있습니다. priceNone이면 :.2f 포맷팅이 실패합니다. 가격이 없을 때는 항상 0.00을 기본값으로 사용하거나 해당 항목을 건너뛰세요.
if __name__ == "__main__":
    create_gmc_feed('ulta_data.jsonl', 'google_feed.xml')

마무리

원시 스크래퍼 데이터를 기능적인 마케팅 자산으로 전환하면 원시 데이터를 비즈니스 가치로 바꿀 수 있습니다. JSONL과 GMC XML 사이의 격차를 연결하면 스크래핑 파이프라인에서 직접 재고 업데이트를 자동화할 수 있습니다.

주요 내용

  • 데이터 스트리밍: 대용량 데이터셋을 처리하려면 JSONL과 라인‑바이‑라인 처리를 사용하세요.
  • 스키마 준수: Google은 형식에 엄격합니다. 가격에 통화 코드를 항상 포함하고 가용성을 세 가지 특정 열거형(enum) 중 하나에 매핑하세요.
  • 파이프라인 자동화: 스크래퍼가 완료된 직후 이 스크립트를 트리거하여 손이 닿지 않는 데이터‑투‑광고 파이프라인을 구축하세요.

초기 추출에 대한 자세한 내용은 ScrapeOps Residential Proxy AggregatorUlta.com‑Scrapers repository에서 전체 구현 범위를 확인하세요.

0 조회
Back to Blog

관련 글

더 보기 »

구리지 않은 시맨틱 무효화

캐싱 문제 웹 애플리케이션을 어느 정도 기간 동안 작업해 본 사람이라면 캐싱에 대한 상황을 잘 알 것입니다. 캐시를 추가하면 모든 것이 빨라지고, 그 다음에 누군가…

그룹별 배열 뒤집기

문제 설명: 주어진 크기 k의 그룹으로 배열을 뒤집는다. 배열은 길이 k인 연속적인 청크(윈도우)로 나뉘며, 각 청크는 뒤집힌다.