Amazon SP-API Feeds APIで出品者出荷の在庫を一括更新(Typescript)

## 要点
- フィードのアップロードステップは下記
  1. **createFeedDocument** でフィードドキュメントを作成（S3の署名付きURLが発行される）
  1. S3にフィードの内容をJSONでアップロード
  1. **createFeed** でフィードを生成
- 2025年3月以降、 JSON_LISTINGS_FEED のフィードタイプでアップロードする必要がある（本記事で対応）
  - XMLのアップロードは非推奨
- フィードのアップロードステータスは[セラーセントラル上で確認できる](https://sellercentral.amazon.co.jp/listing/status)
- 在庫と価格は同時に変更可能
- 最大1万件まで同時に更新可能
- 処理はかなり高速(3000件でも5分以内)

## 事前準備
### セラーセントラルでアプリの作成
[Amazonの新しいAPI、SP-APIをとにかく動かしてみる](https://www.greenwich.co.jp/blog-archives/p/26120)
を参考に、次の3つの情報を用意し環境変数に設定
  - リフレッシュトークン (YOUR_REFRESH_TOKEN)
  - クライアントID (SELLING_PARTNER_APP_CLIENT_ID)
  - クライアント機密情報 (SELLING_PARTNER_APP_CLIENT_SECRET)

なお、2024年9月現在はIAMユーザーは不要

### セラーIDの確認
[AmazonセラーIDを取得](https://amatopia.jp/id-seller-2/#:~:text=%E4%BB%8A%E5%9B%9E%E3%81%AFAmazon%E3%81%AE)し、環境変数に設定(AMAZON_SELLER_ID)

### セラーセントラルのユーザー作成
開発時にセラーセントラルの中を確認できると便利なので、作成しておく
下記の権限をつけておく
1. 在庫の管理/商品の追加
1. トランザクション (表示と編集)

## コード
下記の createFeed() を使用
```createFeed.ts
import axios from 'axios';
import { config } from 'dotenv';
import { SellingPartner } from 'amazon-sp-api';

const JP_MARKET_PLACE_ID = "A1VC38T7YXB528"; //日本のマーケットプレイスID(固定値)

/**
 * 型定義 ============================================
 */

// 商品情報
type Item = {
    readonly sku: string;
    readonly quantity: number;
    readonly leadTime: number;
};

// createFeedDocumentのレスポンス
type ICreateFeedDocumentResBody = {
    readonly feedDocumentId: string;
    readonly url: string;
};

// 以下、Feeds JSONのフォーマット
type IFeedJsonMessageFulfillmentPatchValue = {
    readonly fulfillment_channel_code: "DEFAULT";
    readonly quantity: number;
    readonly lead_time_to_ship_max_days: number;
};

type IFeedJsonMessageFulfillmentPatch = {
    readonly op: "replace";
    readonly path: "/attributes/fulfillment_availability";
    readonly value: IFeedJsonMessageFulfillmentPatchValue[];
};

type IFeedJsonMessage = {
    readonly messageId: number;
    readonly sku: string;
    readonly operationType: "PATCH";
    readonly productType: "PRODUCT";
    readonly patches: IFeedJsonMessageFulfillmentPatch[];
};

type IFeedJson = {
    readonly header: {
        readonly sellerId: string;
        readonly version: "2.0";
        readonly issueLocale: "en_US";
    };
    readonly messages: IFeedJsonMessage[];
};

/**
 * ==================================================================
 */

config(); // 環境変数を読み込む

// Selling Partner APIクライアントの初期化
const spApi = new SellingPartner({
    region: 'fe', // 日本ならfe
    refresh_token: process.env.YOUR_REFRESH_TOKEN,
    credentials: {
        SELLING_PARTNER_APP_CLIENT_ID: process.env.SELLING_PARTNER_APP_CLIENT_ID,
        SELLING_PARTNER_APP_CLIENT_SECRET: process.env.SELLING_PARTNER_APP_CLIENT_SECRET,
    },
    options: {
        auto_request_tokens: true,
    }
});

/**
 * フィードJSONを生成する
 */
const generateFeedJson = async (items: Item[]): Promise<IFeedJson> => {
    const feedJson: IFeedJson = {
        "header": {
            "sellerId": process.env.AMAZON_SELLER_ID,
            "version": "2.0",
            "issueLocale": "en_US"
        },
        "messages": []
    };

    let index = 1;
    for (let item of items) {
        const message = {
            messageId: index,
            sku: item.sku,
            operationType: "PATCH",
            productType: "PRODUCT",
            patches: [{
                "op": "replace",
                "path": "/attributes/fulfillment_availability",
                "value": [
                    {
                        "fulfillment_channel_code": "DEFAULT",
                        "quantity": item.quantity, // 在庫数
                        "lead_time_to_ship_max_days": item.leadTime // 在庫を更新する時はリードタイムも同時に更新する必要がある
                    }
                ]
            }],
        };

        feedJson.messages.push(message);
        index++;
    }

    return feedJson;
};

/**
 * S3に　フィードをアップロードする
 */
const uploadToS3 = async (url: string, feedJson: IFeedJson): Promise<void> => {
    const response = await axios.put(url, feedJson, {
        headers: {
            'Content-Type': 'application/json; charset=utf-8'
        }
    });

    if (response.status !== 200) {
        throw new Error(`フィードアップロードに失敗 code: ${response.status} message: ${response.statusText}`)
    }
}

/**
 * SP-APIのCreateFeedを呼び出す
 * @param feedDocumentId 
 */
const callCreateFeed = async (feedDocumentId: string) => {
    return spApi.callAPI({
        operation: 'createFeed',
        endpoint: 'feeds',
        body: {
            feedType: 'JSON_LISTINGS_FEED',
            marketplaceIds: [JP_MARKET_PLACE_ID],
            inputFeedDocumentId: feedDocumentId
        }
    });
}

/**
* 商品情報を元にフィードを作成し、Amazonにアップロードする
*/
export const createFeed = async (items: Item[]): Promise<IFeedJson | null> => {
    // フィードの中身を生成
    const feedJson = await generateFeedJson(items);
    if (feedJson.messages.length === 0) {
        return null
    }

    // フィードドキュメントの作成
    //@ts-ignore
    const feedDocument: ICreateFeedDocumentResBody = await spApi.callAPI({
        operation: 'createFeedDocument',
        endpoint: 'feeds',
        body: {
            contentType: 'application/json; charset=utf-8'
        }
    });

    // フィードデータを　S3にアップロード
    await uploadToS3(feedDocument.url, feedJson);

    // フィードドキュメントを元に、フィードの作成
    const feed = await callCreateFeed(feedDocument.feedDocumentId);

    console.log('Feed created:', feed); // Feed created: { feedId: '618797019996' }

    return feed.feedId;
}
```

## 参考ドキュメント
SP-APIを利用するうえで非常に参考になる記事

https://qiita.com/Mikeinu/items/b109f958de55e04fb554#feed-api-json_listings_feed%E3%81%AE%E4%BD%BF%E3%81%84%E6%96%B9

Amazon公式のFeeds API ユースケースガイド
（非推奨となったXMLフィードのアップについて触れていたりと、情報が古いところも多い）

https://developer-docs.amazon.com/sp-api/lang-ja_JP/docs/feeds-api-v2021-06-30-use-case-guide#:~:text=Selling%20Pa


## 備考
- 複数のフィードが同じfeedIDであることも多いので注意
  - 約12時間に1ずつ増加する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/181210/12208311-a629-1881-f81f-6831c2249af9.png)