Feed Rescue:将原始 Ulta 抓取数据转换为 Google Merchant Center XML

发布: (2026年2月28日 GMT+8 10:45)
12 分钟阅读
原文: Dev.to

Source: Dev.to

Feed Rescue:将原始 ULTA 抓取数据转换为 Google Merchant Center XML

在这篇文章中,我将分享我们是如何把从 ULTA Beauty(https://www.ulta.com)抓取的原始产品数据,清洗、标准化并最终生成符合 Google Merchant Center(GMC)要求的 XML feed 的完整过程。整个流程包括:

  • 数据抓取(Scraping)
  • 数据清洗与映射(Cleaning & Mapping)
  • 生成符合 GMC 规范的 XML(XML Generation)

下面的示例代码均使用 Ruby 编写,依赖的 gem 包有 nokogirihttpartybuilder 等。代码块保持原样,不做翻译。


1. 背景

我们需要在 Google Shopping 上投放 ULTA 的产品广告。Google 要求的 feed 必须是符合特定 XSD(XML Schema Definition)的 XML 文件,而 ULTA 并未提供官方的商品 feed,只能自行抓取页面并自行构造。

关键点

  • 必须提供 idtitledescriptionlinkimage_linkpricebrandgtinmpn 等必填字段。
  • 需要对价格进行本地化(currency)和税费/运费信息进行补全。

2. 抓取原始数据

我们使用 HTTParty 对 ULTA 的搜索结果页面进行请求,并用 Nokogiri 解析 HTML。下面的代码演示了如何获取每个产品的基本信息:

require 'httparty'
require 'nokogiri'

BASE_URL = 'https://www.ulta.com/search'

def fetch_search_page(query, page = 1)
  response = HTTParty.get(BASE_URL, query: { q: query, page: page })
  Nokogiri::HTML(response.body)
end

def parse_products(doc)
  doc.css('.product-card').map do |card|
    {
      id:          card['data-product-id'],
      title:       card.at_css('.product-title').text.strip,
      price:       card.at_css('.price').text.strip,
      link:        card.at_css('a').[]('href'),
      image_link:  card.at_css('img')[:src]
    }
  end
end

说明

  • fetch_search_page 接受搜索关键字和页码,返回解析后的 Nokogiri::HTML::Document
  • parse_products 从页面中提取每个产品卡片的核心字段。

3. 数据清洗与映射

抓取到的原始数据往往缺少 gtinmpnbrand 等信息,需要进一步访问每个产品的详情页进行补全。我们封装了 fetch_product_detail 方法来完成此工作:

def fetch_product_detail(product_url)
  detail_doc = Nokogiri::HTML(HTTParty.get(product_url).body)

  {
    description: detail_doc.at_css('#productDescription').text.strip,
    brand:       detail_doc.at_css('.brand-name').text.strip,
    gtin:        detail_doc.at_css('meta[itemprop="gtin13"]')&.[]('content'),
    mpn:         detail_doc.at_css('meta[itemprop="sku"]')&.[]('content')
  }
end

def enrich_product(product)
  detail = fetch_product_detail(product[:link])
  product.merge(detail)
end

3.1 价格标准化

Google 要求价格字段的格式为 amount currency_code(例如 29.99 USD)。我们使用以下函数统一处理:

def format_price(raw_price)
  amount = raw_price.gsub(/[^\d\.]/, '').to_f
  "#{format('%.2f', amount)} USD"
end

3.2 过滤无效记录

在生成 feed 前,需要剔除缺少必填字段的记录:

def valid_product?(product)
  required_keys = %i[id title description link image_link price brand]
  required_keys.all? { |k| product[k] && !product[k].empty? }
end

4. 生成 Google Merchant Center XML

Google 提供的 XSD 位于 https://support.google.com/merchants/answer/7052112?hl=en。我们使用 Builder gem 动态构造符合规范的 XML:

require 'builder'

def build_gmc_feed(products)
  xml = Builder::XmlMarkup.new(indent: 2)
  xml.instruct! :xml, version: '1.0', encoding: 'UTF-8'
  xml.rss(version: '2.0', 'xmlns:g' => 'http://base.google.com/ns/1.0') do
    xml.channel do
      xml.title 'ULTA Beauty Product Feed'
      xml.link  'https://www.ulta.com'
      xml.description 'Automatically generated feed for Google Shopping.'

      products.each do |p|
        next unless valid_product?(p)

        xml.item do
          xml['g'].id          p[:id]
          xml['g'].title       p[:title]
          xml['g'].description p[:description]
          xml['g'].link        p[:link]
          xml['g'].image_link  p[:image_link]
          xml['g'].price       format_price(p[:price])
          xml['g'].brand       p[:brand]
          xml['g'].gtin        p[:gtin] if p[:gtin]
          xml['g'].mpn         p[:mpn]  if p[:mpn]
          xml['g'].availability 'in stock'
          xml['g'].condition   'new'
        end
      end
    end
  end
  xml.target!
end

4.1 写入文件

File.open('ulta_gmc_feed.xml', 'w') do |file|
  file.write(build_gmc_feed(enriched_products))
end

5. 自动化与部署

为了让 feed 持续保持最新,我们将上述脚本封装为 Rake 任务,并使用 GitHub Actions 每日凌晨 2 点自动运行:

# .github/workflows/gmc-feed.yml
name: Generate ULTA GMC Feed

on:
  schedule:
    - cron: '0 2 * * *'   # 每天 UTC 02:00

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.1'
      - name: Install dependencies
        run: bundle install
      - name: Generate feed
        run: bundle exec rake generate_gmc_feed
      - name: Upload feed as artifact
        uses: actions/upload-artifact@v2
        with:
          name: ulta_gmc_feed
          path: ulta_gmc_feed.xml

提示

  • 将生成的 XML 上传至 Google Merchant Center 时,建议使用 SFTPGoogle Cloud Storage,并在 GMC 中配置相应的抓取路径。

6. 结论

通过上述步骤,我们成功地:

  1. 抓取 ULTA 的产品页面并提取关键字段。
  2. 补全 缺失的品牌、GTIN、MPN 等信息。
  3. 标准化 价格、可用性等属性以符合 GMC 要求。
  4. 自动生成 符合 XSD 的 XML feed。
  5. 使用 CI/CD(GitHub Actions)实现每日自动更新,确保广告投放的商品信息始终是最新的。

如果你在实现过程中遇到任何问题,欢迎在评论区留言,我会尽快回复。祝大家的 Google Shopping 项目顺利上线!

第 1 阶段 – 分析源数据

在生成任何 XML 之前,我们需要了解原始数据。
Ulta.com‑Scrapers 仓库(Selenium 与 Playwright 版本)会输出一个 JSONL 文件,其中每一行都符合相同的 ScrapedData 数据类。

典型的原始记录:

{
  "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) feed。以下是 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:availability必须是以下之一:in_stockout_of_stockpreorder
brandg:brand可选,但建议提供
(static)g:condition所有产品均设为new

第 3 阶段 – 字段映射与转换逻辑

1. 价格标准化

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. 可用性映射

爬虫已经返回 in_stockout_of_stockpreorder
为安全起见,我们将任何意外值映射为 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. 图片处理

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

第4阶段 – 构建转换脚本

The pipeline streams the JSONL file, transforms each record, and writes a pretty‑printed XML feed using xml.etree.ElementTree.

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. 根据第2阶段的表格映射每个字段,并使用帮助函数处理价格、可用性和图片。
  4. 将格式化且缩进良好的 XML 写入文件。

ulta_gmc_feed.xml 已准备好上传至 Google Merchant Center。

第5阶段 – 处理边缘情况

Web 数据往往杂乱无章。以下是处理 Ulta 抓取时常见的三类问题:

  1. 描述中的 HTML – Ulta 的描述有时包含原始 HTML 标签或类似   的实体。虽然爬虫会清理大部分内容,但更安全的做法是将描述包装在 CDATA 区段中,或使用正则表达式去除剩余标签后再插入 XML。
  2. 绝对 URL – 确保你的爬虫使用仓库中的 make_absolute_url 逻辑。Google 会拒绝类似 /p/product-name 的相对 URL。
  3. 零或缺失的价格 – 有时,产品可能显示“价格不固定”或“缺货”,而没有数值。如果 priceNone:.2f 格式化会失败。请始终默认使用 0.00,或在缺少价格时跳过该商品。
if __name__ == "__main__":
    create_gmc_feed('ulta_data.jsonl', 'google_feed.xml')

总结

将原始爬虫数据转换为可用的营销资产,将原始数据转化为业务价值。弥合 JSONL 与 GMC XML 之间的差距,使您能够直接从爬取管道自动更新库存。

关键要点

  • 流式处理数据: 使用 JSONL 和逐行处理来应对大规模数据集。
  • 遵循模式: Google 对格式要求严格。价格中必须包含货币代码,并将可用性映射到其三个特定枚举值。
  • 自动化管道: 在爬虫完成后立即触发此脚本,创建一个无需人工干预的数据到广告的管道。

欲了解初始提取的更多信息,请查看 ScrapeOps Residential Proxy Aggregator 并访问 Ulta.com‑Scrapers repository 获取完整实现。

0 浏览
Back to Blog

相关文章

阅读更多 »

不糟糕的语义失效

缓存问题 如果你在 Web 应用上工作了一段时间,你就会了解缓存的情况。你加入缓存,一切都变快了,然后有人……

按组反转数组

问题描述:将数组按给定大小 k 分组后逆序。数组被划分为长度为 k 的连续块(窗口),每个块都被逆序。