Feed Rescue:将原始 Ulta 抓取数据转换为 Google Merchant Center XML
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 包有 nokogiri、httparty、builder 等。代码块保持原样,不做翻译。
1. 背景
我们需要在 Google Shopping 上投放 ULTA 的产品广告。Google 要求的 feed 必须是符合特定 XSD(XML Schema Definition)的 XML 文件,而 ULTA 并未提供官方的商品 feed,只能自行抓取页面并自行构造。
关键点
- 必须提供
id、title、description、link、image_link、price、brand、gtin、mpn等必填字段。- 需要对价格进行本地化(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. 数据清洗与映射
抓取到的原始数据往往缺少 gtin、mpn、brand 等信息,需要进一步访问每个产品的详情页进行补全。我们封装了 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 时,建议使用 SFTP 或 Google Cloud Storage,并在 GMC 中配置相应的抓取路径。
6. 结论
通过上述步骤,我们成功地:
- 抓取 ULTA 的产品页面并提取关键字段。
- 补全 缺失的品牌、GTIN、MPN 等信息。
- 标准化 价格、可用性等属性以符合 GMC 要求。
- 自动生成 符合 XSD 的 XML feed。
- 使用 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 标签 | 要求 / 备注 |
|---|---|---|
productId | g:id | 唯一标识符 |
name | g:title | 最大150字符 |
description | g:description | 纯文本,且无损坏的HTML |
url | g:link | 绝对URL |
images[0]['url'] | g:image_link | 高分辨率主图像 |
price + currency | g:price | 格式:17.99 USD |
availability | g:availability | 必须是以下之一:in_stock、out_of_stock、preorder |
brand | g: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_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. 图片处理
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")
脚本功能
- 注册 Google 所需的
g:命名空间。 - 逐行流式读取输入的 JSONL 文件(保持恒定内存占用)。
- 根据第2阶段的表格映射每个字段,并使用帮助函数处理价格、可用性和图片。
- 将格式化且缩进良好的 XML 写入文件。
ulta_gmc_feed.xml 已准备好上传至 Google Merchant Center。
第5阶段 – 处理边缘情况
Web 数据往往杂乱无章。以下是处理 Ulta 抓取时常见的三类问题:
- 描述中的 HTML – Ulta 的描述有时包含原始 HTML 标签或类似
的实体。虽然爬虫会清理大部分内容,但更安全的做法是将描述包装在CDATA区段中,或使用正则表达式去除剩余标签后再插入 XML。 - 绝对 URL – 确保你的爬虫使用仓库中的
make_absolute_url逻辑。Google 会拒绝类似/p/product-name的相对 URL。 - 零或缺失的价格 – 有时,产品可能显示“价格不固定”或“缺货”,而没有数值。如果
price为None,:.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 获取完整实现。