把整理好的商品数据(JSON)转成 Shopify 后台可以直接导入的商品 CSV:处理多变体、多图片的行合并,检查 handle 格式、价格格式和图片地址,有错误就退出并列出所有问题。列名按 Shopify 帮助中心当前版本的 CSV 文档。
先看清成品
本机用 pytest 跑了 3 个测试,全部通过。示例数据第一次转换报了 2 个错误(价格带了
¥、图片用了 http),改正后输出 4 行 CSV,退出码 0。没有在真实店铺后台导入,导入结果以 Shopify 后台的预览和报错为准。
注意:Shopify 的套餐价格、交易费和应用收费以官方价格页和你所在地区的条款为准,本文不列数字。只上架你有权销售的商品和有授权的图片;不要共享店铺员工账号,每个人用自己的员工账号登录。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
to_shopify_csv.py |
JSON → CSV,UTF-8 编码、LF 换行 |
| 行合并 | 第一行写商品信息,后面几行只写变体或图片 |
| 校验 | handle、重复、价格、https 图片、alt 长度 |
| 导入方式 | 先用 draft 状态导入,检查后再上架 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Python | 3.9 以上(本机 3.13) | 只用标准库,测试要 pytest |
| 图片 | 公开可访问的 https 地址 | 例如先上传到后台 Content → Files |
| 店铺后台权限 | 能导入商品 | 后台 Products → Import |
文件结构
28-shopify-B-src/
├── products.json # 改正后的示例
├── products.bad.json # 带 2 个错误的原始示例
├── to_shopify_csv.py
├── test_to_csv.py
└── out.csv # 本机生成的结果
第 1 步:官方 CSV 的几条规则
| 规则 | 来源(帮助中心 CSV 页面) |
|---|---|
新建商品时只有 Title 必填;有变体时还要填 URL handle |
Required columns |
| handle 只能用字母、数字和连字符,不能有空格 | URL handle |
| 价格只写数字,不带货币符号 | Price |
| 有变体时,第一行写全商品信息,后面的行只写 handle 和变体信息 | Create your product CSV file |
| 每张图片占一行,图片地址必须公开、用 https | Adding multiple product images |
| 文件用 UTF-8 编码、LF 换行 | Create your product CSV file |
Status 可以是 active / draft / archived |
Status |
脚本里的 handle 规则比官方更严格:只允许小写字母,避免大小写不同的 handle 造成混淆。
第 2 步:输入数据
products.json:
[
{
"title": "手冲咖啡豆 耶加雪菲 250g",
"handle": "yirgacheffe-250g",
"description": "<p>浅烘,柑橘与花香。</p>",
"vendor": "Demo Roastery",
"tags": ["coffee", "light-roast"],
"status": "draft",
"option_name": "研磨",
"variants": [
{"value": "整豆", "price": "88.00", "sku": "YG-250-WB", "qty": 20},
{"value": "手冲粗细", "price": "88.00", "sku": "YG-250-PO", "qty": 10}
],
"images": [
{"url": "https://example.com/img/yg-1.jpg", "alt": "耶加雪菲包装正面"},
{"url": "https://example.com/img/yg-2.jpg", "alt": "咖啡豆特写"},
{"url": "https://example.com/img/yg-3.jpg", "alt": "冲煮示意"}
]
},
{
"title": "V60 滤纸 100 张",
"handle": "v60-filter-100",
"description": "<p>白色无漂白。</p>",
"vendor": "Demo Roastery",
"tags": ["accessories"],
"status": "draft",
"variants": [{"price": "25", "sku": "FLT-100", "qty": 50}],
"images": [{"url": "https://example.com/img/filter.jpg", "alt": "滤纸"}]
}
]
只有一个规格的商品不写 option_name,脚本会让 Option 两列留空。
第 3 步:转换脚本
to_shopify_csv.py:
"""把 products.json 转成 Shopify 商品导入 CSV。列名按 Shopify 帮助中心
“Using CSV files to import and export products” 当前版本。"""
import csv, json, re, sys
from decimal import Decimal, InvalidOperation
COLUMNS = ["URL handle", "Title", "Description", "Vendor", "Tags", "Published on online store",
"Status", "Option1 name", "Option1 value", "SKU", "Price", "Inventory quantity",
"Product image URL", "Image position", "Image alt text"]
HANDLE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
def money(v, where, errors):
try:
d = Decimal(str(v))
if d < 0: raise InvalidOperation
return f"{d:.2f}"
except InvalidOperation:
errors.append(f"{where}: 价格必须是不带货币符号的数字,收到 {v!r}")
return ""
def build(products):
rows, errors, seen = [], [], set()
for p in products:
h = p.get("handle", "")
if not HANDLE.match(h): errors.append(f"{h!r}: handle 只能用小写字母、数字和连字符")
if h in seen: errors.append(f"{h}: handle 重复")
seen.add(h)
variants, images = p.get("variants") or [{}], p.get("images", [])
if len(variants) > 1 and not p.get("option_name"):
errors.append(f"{h}: 多个变体必须有 option_name")
for img in images:
if not img["url"].startswith("https://"): errors.append(f"{h}: 图片要用公开的 https 地址 {img['url']}")
if len(img.get("alt", "")) > 512: errors.append(f"{h}: alt 超过 512 字符")
for i in range(max(len(variants), len(images))):
r = dict.fromkeys(COLUMNS, "")
r["URL handle"] = h
if i == 0: # 第一行写商品级字段
r.update({"Title": p["title"], "Description": p.get("description", ""), "Vendor": p.get("vendor", ""),
"Tags": ", ".join(p.get("tags", [])), "Published on online store": "true",
"Status": p.get("status", "draft")})
if i < len(variants):
v = variants[i]
if p.get("option_name"):
r["Option1 name"], r["Option1 value"] = p["option_name"], v["value"]
r["SKU"] = v.get("sku", "")
r["Price"] = money(v.get("price", "0"), f"{h} 变体 {i+1}", errors)
r["Inventory quantity"] = str(v.get("qty", 0))
if i < len(images):
r["Product image URL"], r["Image position"], r["Image alt text"] = images[i]["url"], str(i + 1), images[i].get("alt", "")
rows.append(r)
return rows, errors
if __name__ == "__main__":
rows, errors = build(json.load(open(sys.argv[1], encoding="utf-8")))
with open(sys.argv[2], "w", encoding="utf-8", newline="") as f: # UTF-8 + LF,按官方要求
w = csv.DictWriter(f, COLUMNS, lineterminator="\n"); w.writeheader(); w.writerows(rows)
print(f"{len(rows)} 行写入 {sys.argv[2]}")
for e in errors: print("ERROR", e)
sys.exit(1 if errors else 0)
第 4 步:测试与运行
test_to_csv.py:
from to_shopify_csv import build
def P(**kw):
base = {"title": "T", "handle": "t-1", "variants": [{"price": "9.9", "sku": "A"}], "images": []}
base.update(kw); return base
def test_variant_and_image_rows_merge():
rows, err = build([P(option_name="Size", variants=[{"value": "S", "price": 1}, {"value": "M", "price": 1}],
images=[{"url": "https://x/1.jpg"}, {"url": "https://x/2.jpg"}, {"url": "https://x/3.jpg"}])])
assert err == [] and len(rows) == 3
assert rows[0]["Title"] == "T" and rows[1]["Title"] == ""
assert rows[2]["Option1 value"] == "" and rows[2]["Image position"] == "3"
def test_price_format():
rows, err = build([P(variants=[{"price": "9.9"}])])
assert rows[0]["Price"] == "9.90"
_, err = build([P(variants=[{"price": "¥25"}])])
assert "货币符号" in err[0]
def test_handle_rules():
_, err = build([P(handle="Bad Handle"), P(handle="t-1"), P(handle="t-1")])
assert any("小写" in e for e in err) and any("重复" in e for e in err)
python -m pytest -q # 3 passed
python to_shopify_csv.py products.json out.csv
out.csv:
URL handle,Title,Description,Vendor,Tags,Published on online store,Status,Option1 name,Option1 value,SKU,Price,Inventory quantity,Product image URL,Image position,Image alt text
yirgacheffe-250g,手冲咖啡豆 耶加雪菲 250g,<p>浅烘,柑橘与花香。</p>,Demo Roastery,"coffee, light-roast",true,draft,研磨,整豆,YG-250-WB,88.00,20,https://example.com/img/yg-1.jpg,1,耶加雪菲包装正面
yirgacheffe-250g,,,,,,,研磨,手冲粗细,YG-250-PO,88.00,10,https://example.com/img/yg-2.jpg,2,咖啡豆特写
yirgacheffe-250g,,,,,,,,,,,,https://example.com/img/yg-3.jpg,3,冲煮示意
v60-filter-100,V60 滤纸 100 张,<p>白色无漂白。</p>,Demo Roastery,accessories,true,draft,,,FLT-100,25.00,50,https://example.com/img/filter.jpg,1,滤纸
检查结果:耶加雪菲有 2 个变体、3 张图,合并成 3 行,只有第 1 行有标题;第 3 行只有图片。
第 5 步:导入(本机未执行)
- 后台 Products → Import,上传
out.csv。 - 第一次导入不要勾选 “Overwrite products with matching handles”。
- 看预览,确认没问题后导入。商品是
draft状态,检查完再改成 active。
调试示例
第一次运行退出码是 1
$ python to_shopify_csv.py products.bad.json out.csv
4 行写入 out.csv
ERROR v60-filter-100: 图片要用公开的 https 地址 http://example.com/img/filter.jpg
ERROR v60-filter-100 变体 1: 价格必须是不带货币符号的数字,收到 '¥25'
价格是从中文表格里复制过来的,带了 ¥;图片地址是旧站的 http 链接。把价格改成 25、图片换成 https 后,退出码变成 0。脚本会一次列出所有错误,不会遇到第一个就停。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 后台导入后中文是乱码 | 用 Excel 另存为了别的编码 | 用 UTF-8 保存,或者直接上传脚本的输出 |
| 变体变成了独立商品 | 变体行的 handle 和第一行不一致 | 同一商品的每一行 handle 都要相同 |
| 现有变体被删掉了 | 覆盖导入时缺了 Option 列 | 更新已有商品时要带上 Option1 name/value |
| 图片没导入 | 地址需要登录才能访问,或者不是 https | 换成公开的 https 地址 |
模板规则
- 源数据用 JSON 保存,CSV 只作为导入用的输出。
- 先用 draft 状态导入,确认后再上架。
- 覆盖导入前先从后台导出备份。
- 列名以帮助中心的当前版本为准,新旧模板不要混用。
项目资源
商品 CSV 最容易出错的地方是“一个商品占多行”。用脚本生成,再加上校验,就不用在表格里手动对齐行了。