Claude Media
Claude CodeでShopifyの商品を移行する — productSetで冪等に登録

Claude CodeでShopifyの商品を移行する — productSetで冪等に登録

旧ECの商品CSVをShopifyのproductSetに写す手順です。handleを鍵にして何度でも流し直せる形にし、登録後はSKUと件数をCSVと突き合わせます。

Claude CodeでShopifyの商品を移行する — productSetで冪等に登録

旧ECの商品CSVをShopifyへ移すなら、productSet ミューテーションに1商品ずつ「完成形」を渡す方法が向いています。商品を handle で指名して登録するため、同じ入力を何度流しても結果が変わりません。途中で落ちても、最初から流し直せます。

Claude Codeに任せるのは、CSVを productSet の入力に写す変換スクリプトと、登録後の照合スクリプトの2つです。本番ストアへの送信は、トークンを持つ別のスクリプトに分けます。この記事では、その分担と、実際に手元で動かして確かめた変換・照合の出力を示します。

productSetは商品の完成形を宣言する

productSet は、外部のデータをShopifyに同期するための操作で、1回のリクエストで商品・オプション・バリエーションをまとめて作成・更新します。動きは項目の種類で分かれます。

  • リスト項目(variants、metafields、collections など):入力に無い既存の要素は削除される
  • それ以外の項目:入力に含めた項目だけが更新され、省略した項目は変わらない

移行で効いてくるのは前者です。バリエーションが5つある商品に3つだけ渡すと、残りの2つは消えます。CSVの一部の行だけを流す「差分投入」をすると、バリエーションが意図せず減ります。移行では、商品1件につきCSVの該当行をすべて揃えて渡す前提で組みます。

仕様

productSetの更新ルール

  • リスト項目は全置換

    variants などは、入力に無いものが削除されます。

  • その他の項目は差分

    省略した項目は変わりません。ただし tags を渡すと既存のタグは上書きされます。

  • 実行モードは2つ

    同期(既定)は商品をそのまま返します。非同期は ProductSetOperation を返し、productOperation で結果を引きます。

必要な権限は write_products アクセススコープと、商品を作成できるユーザー権限です。バリエーションの上限は、商品1件につき既定で2048です。

handleで商品を指名すると、再実行しても同じ結果になる

productSet の identifier 引数には、id、handle、customId(メタフィールドの一意な値)の3通りの指名方法があります。旧ECの商品に共通して付いているのは、URLに使う識別子です。そのまま handle に使えば、移行元に新しいID体系を持ち込まずに済みます。

handle で指名すると、その商品が無ければ作成し、あれば更新します。入力側にも同じ handle を入れておきます。

{
  "identifier": { "handle": "winter-hat" },
  "input": { "handle": "winter-hat", "title": "Winter hat" }
}

2回目以降の実行は更新になるので、重複商品はできません。ここが冪等な移行の核です。

旧ECのCSVをoptionsとvariantsに写す

旧ECのCSVは、1行が1バリエーションで、商品名などの商品単位の列は先頭行にだけ入っている形が多く見られます。productSet に渡す形は次のとおりです。

CSVの列productSetの入力
handleproductSetの入力identifier.handle と input.handle
オプション名・値productSetの入力productOptions[].name と values[].name
行ごとのオプション値productSetの入力variants[].optionValues[](optionName と name)
SKUproductSetの入力variants[].sku(大文字小文字を区別する文字列)
価格productSetの入力variants[].price
在庫数productSetの入力variants[].inventoryQuantities[](locationId・name・quantity)
画像URLproductSetの入力files[].originalSource

数値や上限で、先に知っておきたいものは次のとおりです。

  • カスタムオプションは1商品につき最大3つです
  • 配列の入力は、どのShopify APIでも最大250件です。1商品で250バリエーションを超える場合は、1回の productSet では送れません
  • 在庫の name には available を指定します。ロケーションIDは gid://shopify/Location/... の形です
  • 既存のバリエーションを更新するとき、在庫を書き換えられるのは、そのバリエーションがすでに置かれているロケーションだけです
  • 1回のミューテーションに含まれる在庫数量は、全バリエーション合計で50000までです

画像は files に originalSource(取得元URL)、alt、contentType: "IMAGE" を渡します。バリエーションに画像を紐づける file を使う場合は、同じファイルを商品の files にも入れておく必要があります。

変換スクリプトを書かせる

次は、CSVを1商品1行のJSONLに変換するPythonスクリプトです。Claude Codeに「このCSVの列を、上の対応表どおりに productSet の入力へ変換して」と頼んで書かせる想定の例で、自分の環境でも動かして挙動を確かめた形で載せます。

import csv, json, sys, collections
 
LOCATION_ID = sys.argv[2]  # gid://shopify/Location/...
rows = collections.defaultdict(list)
with open(sys.argv[1], newline="", encoding="utf-8") as f:
    for r in csv.DictReader(f):
        rows[r["handle"]].append(r)
 
for handle, rs in rows.items():
    head = rs[0]
    names = [head[k] for k in ("opt1_name", "opt2_name") if head[k]]
    if not names:                        # オプションなし商品は別扱いにする
        print(f"skip (no options): {handle}", file=sys.stderr)
        continue
    values = {n: [] for n in names}      # 出現順を保つ
    variants, files, seen = [], [], set()
    for r in rs:
        ov = []
        for i, n in enumerate(names, 1):
            v = r[f"opt{i}_value"]
            if v not in values[n]:
                values[n].append(v)
            ov.append({"optionName": n, "name": v})
        variants.append({
            "sku": r["sku"],
            "price": r["price"],
            "optionValues": ov,
            "inventoryQuantities": [{
                "locationId": LOCATION_ID, "name": "available",
                "quantity": int(r["qty"])}],
        })
        if r["image_url"] and r["image_url"] not in seen:
            seen.add(r["image_url"])
            files.append({"originalSource": r["image_url"],
                          "alt": head["title"], "contentType": "IMAGE"})
    if len(variants) > 250:              # 入力配列の上限
        print(f"skip (>250 variants): {handle}", file=sys.stderr)
        continue
    options = [{"name": n, "values": [{"name": v} for v in values[n]]}
               for n in names]
    print(json.dumps({
        "identifier": {"handle": handle},
        "input": {"handle": handle, "title": head["title"],
                  "vendor": head["vendor"], "status": "DRAFT",
                  "productOptions": options, "variants": variants,
                  "files": files}}, ensure_ascii=False))

status は DRAFT で登録し、公開は照合が済んでから別に切り替える運用にしています。オプションを持たない商品は、この変換には含めず、標準エラーに名前を出してスキップします。

入力に使ったのは、次の4行の小さなCSVです。winter-hat が3バリエーション、bike-frame はオプションなしの1行です。

python3 -I csv_to_productset.py products.csv \
  gid://shopify/Location/1 > out.jsonl

標準エラーには次の1行が出ました。

skip (no options): bike-frame

out.jsonl には winter-hat の1行だけが入り、オプションは Color(Grey・Black)と Size(M・L)、バリエーションは3件、画像は2枚でした。スキップされた商品が黙って消えないよう、標準エラーの出力は件数照合の前に必ず確認します。

本番へ送る前にClaude Codeの権限を絞る

変換と照合はClaude Codeに任せられますが、Shopifyのアクセストークンは渡しません。ストアへ送るスクリプトだけがトークンを環境変数から読み、Claude Codeは変換結果のファイルまでを扱います。

.claude/settings.json で、トークンを書いた .env の読み取りと、任意の curl を止めておきます。

{
  "permissions": {
    "deny": ["Read(./.env)", "Bash(curl *)"]
  }
}

作業の約束事は CLAUDE.md に短く書いておくと、毎回の指示が減ります。

## Shopify商品移行
- トークンは読まない・表示しない。`SHOPIFY_TOKEN` は push.py だけが使う
- productSet は variants を全置換する。CSV は handle ごとに全行そろえて変換する
- 変換後は out.jsonl の行数と標準エラーのスキップ数を CSV の handle 数と突き合わせて報告する

変換のやり直しをスクリプトから呼ぶなら、claude -p で非対話実行できます。許可する操作は --allowedTools で列挙します。

claude -p "products.csv を out.jsonl に変換し、スキップされた商品を報告して" \
  --allowedTools "Read,Edit,Bash(python3 csv_to_productset.py *)"

claude -p は、成功すると終了コード0、失敗すると0以外で終わります。スクリプトの分岐に使えます。結果を機械で読みたいときは、--output-format json と --json-schema を併用すると、スキーマに沿った結果が structured_output に入ります。

送信は小さく始め、レート制限を見る

送信側のスクリプトは、JSONLを1行ずつ productSet に流すだけの形にします。返す項目に sku を含めておくと、そのまま照合の材料になります。

import json, os, sys, urllib.request
 
URL = (f"https://{os.environ['SHOP']}.myshopify.com"
       f"/admin/api/2026-10/graphql.json")
MUTATION = """
mutation($input: ProductSetInput!, $identifier: ProductSetIdentifiers) {
  productSet(input: $input, identifier: $identifier) {
    product { handle variants(first: 250) { nodes { sku } } }
    userErrors { field message }
  }
}"""
 
for line in open(sys.argv[1], encoding="utf-8"):
    body = json.loads(line)
    req = urllib.request.Request(
        URL, method="POST",
        headers={"Content-Type": "application/json",
                 "X-Shopify-Access-Token": os.environ["SHOPIFY_TOKEN"]},
        data=json.dumps({"query": MUTATION, "variables": body}).encode())
    res = json.load(urllib.request.urlopen(req))
    print(json.dumps(res, ensure_ascii=False))

最初は開発ストアで1商品だけ流し、userErrors が空であることと、管理画面での見え方を確かめます。実ストアに流すのは、そのあとです。

GraphQL Admin APIのレート制限は、リクエスト数ではなく、問い合わせの計算コストで決まります。バケットの補充速度は、Standardプランが毎秒100ポイント、Advanced Shopifyが200、Shopify Plusが1000です。1回のクエリのコストは、プランに関係なく1000ポイントまでです。

productSet の入力にも、項目ごとのコストが付いています。

入力コスト
バリエーション1件コスト0.2
ファイル1件コスト1.9
商品のメタフィールド1件コスト0.4

画像の多い商品ほどコストが重くなります。入力が大きくてタイムアウトする場合は、synchronous: false にして非同期で実行し、productOperation クエリで COMPLETE か FAILED になるまで状態を引きます。FAILED のときは userErrors に原因が入ります。

新規にバリエーションを作る移行では、もう1つ制限があります。ストアのバリエーションが50万に達すると、Shopify Plus以外のストアは、1日に作れる新規バリエーションが1万件までになります。大規模な移行は、数日に分ける計画が要ります。

登録後にSKUと件数をCSVと突き合わせる

送信が終わったら、レスポンスを保存した resp.jsonl とCSVを突き合わせます。見るのは、商品数(handle数)と、商品ごとのSKU集合の過不足です。

import csv, json, sys, collections
 
want = collections.defaultdict(set)          # handle -> CSV の SKU 集合
for r in csv.DictReader(open(sys.argv[1], newline="", encoding="utf-8")):
    want[r["handle"]].add(r["sku"])
 
got = {}                                     # handle -> Shopify 側の SKU 集合
for line in open(sys.argv[2], encoding="utf-8"):
    p = json.loads(line)["data"]["productSet"]
    if p["userErrors"] or not p["product"]:
        print("ERROR", p["userErrors"]); continue
    pr = p["product"]
    got[pr["handle"]] = {v["sku"] for v in pr["variants"]["nodes"]}
 
print(f"products: csv={len(want)} shopify={len(got)}")
for h in sorted(want):
    miss, extra = want[h] - got.get(h, set()), got.get(h, set()) - want[h]
    if miss or extra:
        print(h, "missing:", sorted(miss), "extra:", sorted(extra))

手元でSKUの1つ(HAT-BLK-M)をわざと欠いた応答を作って流したところ、出力は次のようになりました。

products: csv=2 shopify=2
winter-hat missing: ['HAT-BLK-M']

products の行はhandle数の一致を、missing の行はSKUの欠落を示します。この例では、オプションなしでスキップした bike-frame も、CSV側のhandle数に含まれます。スキップした商品の分は、件数が合っていても別に登録が必要です。

照合で差が出たら、その商品のCSV行を直して同じ handle で流し直します。productSet は完成形で置き換えるので、直した行を含む商品全体を再送すれば足ります。

流し直しで事故になる3つの型

落とし穴

一部の行だけ再送すると、残りのバリエーションが消える

直したいバリエーションの1行だけを variants に入れて再送すると、同じ商品のほかのバリエーションは削除されます。再送するときは、商品の全行をそろえます。

落とし穴

tagsを渡すと既存のタグが上書きされる

tags は渡した内容で置き換わります。店舗側で後から付けたタグを残したい移行では、入力に tags を含めないか、タグだけを追加する別のミューテーションで扱います。

落とし穴

1回の入力が大きすぎてタイムアウトする

画像やバリエーションの多い商品は、同期実行ではタイムアウトすることがあります。非同期実行に切り替え、1行ずつ送る今回の構成のまま、状態の確認だけを後ろに回します。

バリエーションだけを個別に扱いたい場合は、productVariantsBulkCreate、productVariantsBulkUpdate、productVariantsBulkDelete が用意されています。オプションだけなら productOptionsCreate などです。移行のように商品全体を元データに合わせたいときは productSet、運用中に一部だけ直すときは個別のミューテーションと、使い分けられます。

移行の流れをまとめる

運用でのストア操作は、ClaudeにShopifyのEC運営を任せる実践パターンにあります。移行の前段にあたる項目の対応表づくりは、Claude Codeでデータ移行の項目マッピング表を作るの手順がそのまま使えます。開発ストアでShopifyのドキュメントやスキーマを引きながら進めるなら、Claude CodeでShopify AI Toolkitを使うの入れ方が役立ちます。

この記事の構成を一言で言うと、AIには変換と照合だけを任せ、送信は権限を絞った別のスクリプトに置く、ということです。handle を鍵にした完成形の登録なら、照合で差が出ても、直した商品だけを同じ手順で流し直せます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. shopify.dev/docs/api/admin-graphql/latest/mutations/productSet
  2. shopify.dev/docs/api/admin-graphql/latest/input-objects/ProductSetInput
  3. shopify.dev/docs/api/admin-graphql/latest/input-objects/ProductVariant…
  4. shopify.dev/docs/apps/build/product-merchandising/products-and-collect…
  5. shopify.dev/docs/api/usage/limits
  6. shopify.dev/docs/apps/build/apis/graphql-admin/rate-limits
  7. code.claude.com/docs/en/headless
この記事を共有:XはてブLinkedIn