2024/01/01 から、Amazon アソシエイトを使った Amazon の商品画像表示が出来なくなりました。

こちらのブログ記事で知りました。Product Advertising API (PA-API) を使う必要があるようです。
🔗 2024年1月1日からAmazonアフィリンクの画像が表示できなくなりそうなので、対応をまとめた: わたしが知らないスゴ本は、きっとあなたが読んでいる

本ブログも Amazon アソシエイトで商品画像を表示していたので、年明けから Amazon の商品画像がすべてリンク切れになりました。

仕方がないので、Product Advertising API (PA-API) 5.0 を使って、過去記事で参照している Amazon の商品画像情報を全て取得し、ローカルで保持することにしました。

このように進めました。

  1. Product Advertising API の認証キーを取得する
  2. 自分のトラッキング ID をメモしておく
  3. API テスト実行ツールを使って、PHP の Code snippet を取得する
  4. PHP の Code snippet を Docker で動かして、1 つの商品画像情報を 1 つの json ファイルとして保存する

13 は前述のブログで解説されているので、割愛します。

4 を解説していきます。

実装の概要

macOS Sonoma 上にこのように実装しました。

.
├── Makefile
├── amazon_affiliate
│   ├── .envrc
│   ├── Dockerfile
│   ├── Makefile
│   ├── asins.txt
│   ├── compose.yml
│   ├── entrypoint.bash
│   └── get_image_info.php
└── data
    └── amazon_items
        ├── 4003115015.json
        ├── 4022645237.json

amazon_affiliate/ 以下に実装コード関連ファイルを作りました。

例えば以下の make コマンドを実行すると、ASIN 4003115015 に対応する商品画像情報を data/amazon_items/4003115015.json として作成します。

$ make data/amazon_items/4003115015.json

data/amazon_items/4003115015.json の内容です。

{
  "ASIN": "4003115015",
  "DetailPageURL": "https://www.amazon.co.jp/dp/4003115015?tag=masutaka04-22&linkCode=ogi&th=1&psc=1",
  "Images": {
    "Primary": {
      "Large": {
        "Height": 500,
        "URL": "https://m.media-amazon.com/images/I/51bQQhcQqXL._SL500_.jpg",
        "Width": 357
      }
    }
  }
}

以下の make コマンドを実行すると、amazon_affiliate/asins.txt に羅列された全ての商品について、data/amazon_items/<ASIN>.json を作成します。

$ make amazon_items

以下の make コマンドを実行すると、amazon_affiliate/asins.txt に羅列された全ての商品について、data/amazon_items/<ASIN>.json を削除します。

$ make clean_amazon_items

実装の詳細

1. get_image_info.php の作成

今回のメイン処理である PHP のコードを用意していきます。このような json を返す PHP プログラムです。

{
  "ItemsResult": {
    "Items": [
      {
        "ASIN": "4003115015",
        "DetailPageURL": "https://www.amazon.co.jp/dp/4003115015?tag=masutaka04-22&linkCode=ogi&th=1&psc=1",
        "Images": {
          "Primary": {
            "Large": {
              "Height": 500,
              "URL": "https://m.media-amazon.com/images/I/51bQQhcQqXL._SL500_.jpg",
              "Width": 357
            }
          }
        }
      }
    ]
  }
}

💡 DetailPageURL が自分のパートナータグ(アソシエイトID)と紐づく Amazon の 商品 URL で、Images.Primary.Large.URL が商品画像の Large URL です。

API テスト実行ツール のサイドメニュー ITEM > GetItems をクリックして、以下のフィールドを埋めます。

  • Partner Tag
  • Access Key
  • Secret Key
  • ItemIds
    • ASIN を指定する。例: 4003115015
  • Resources
    • 商品画像の大きさ。今回は Images.Primary.Large にした

Run request すると、Rendered response に商品画像が表示されつつ、Code snippets に JAVA, PHP, cURL のコードを確認できると思います。

今回は PHP のコードを get_image_info.php として保存しました。さらに以下の変更を加えて、前述の Partner Tag をはじめとする、5 つのパラメータを環境変数として注入できるようにしました。

diff --git a/amazon_affiliate/get_image_info.php b/amazon_affiliate/get_image_info.php
index 621c4f3a..01f24c60 100644
--- a/amazon_affiliate/get_image_info.php
+++ b/amazon_affiliate/get_image_info.php
@@ -6,16 +6,16 @@
 // Put your Secret Key in place of **********
 $serviceName="ProductAdvertisingAPI";
 $region="us-west-2";
-$accessKey="AKIAIO4JUZQ7C7T2L2UQ";
-$secretKey="**********";
+$accessKey=getenv('AMAZON_ACCESS_KEY');
+$secretKey=getenv('AMAZON_SECRET_KEY');
 $payload="{"
         ." \"ItemIds\": ["
-        ."  \"B00T8RUFTW\""
+        ."  \"". getenv('ASIN') ."\""
         ." ],"
         ." \"Resources\": ["
-        ."  \"Images.Primary.Large\""
+        ."  \"". getenv('RESOURCE') ."\""
         ." ],"
-        ." \"PartnerTag\": \"masutaka04-22\","
+        ." \"PartnerTag\": \"". getenv('PARTNER_TAG') ."\","
         ." \"PartnerType\": \"Associates\","
         ." \"Marketplace\": \"www.amazon.co.jp\""
         ."}";

最終的な get_image_info.php がこちらです。

<?php

/* Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. */
/* Licensed under the Apache License, Version 2.0. */

// Put your Secret Key in place of **********
$serviceName="ProductAdvertisingAPI";
$region="us-west-2";
$accessKey=getenv('AMAZON_ACCESS_KEY');
$secretKey=getenv('AMAZON_SECRET_KEY');
$payload="{"
        ." \"ItemIds\": ["
        ."  \"". getenv('ASIN') ."\""
        ." ],"
        ." \"Resources\": ["
        ."  \"". getenv('RESOURCE') ."\""
        ." ],"
        ." \"PartnerTag\": \"". getenv('PARTNER_TAG') ."\","
        ." \"PartnerType\": \"Associates\","
        ." \"Marketplace\": \"www.amazon.co.jp\""
        ."}";
$host="webservices.amazon.co.jp";
$uriPath="/paapi5/getitems";
$awsv4 = new AwsV4 ($accessKey, $secretKey);
$awsv4->setRegionName($region);
$awsv4->setServiceName($serviceName);
$awsv4->setPath ($uriPath);
$awsv4->setPayload ($payload);
$awsv4->setRequestMethod ("POST");
$awsv4->addHeader ('content-encoding', 'amz-1.0');
$awsv4->addHeader ('content-type', 'application/json; charset=utf-8');
$awsv4->addHeader ('host', $host);
$awsv4->addHeader ('x-amz-target', 'com.amazon.paapi5.v1.ProductAdvertisingAPIv1.GetItems');
$headers = $awsv4->getHeaders ();
$headerString = "";
foreach ( $headers as $key => $value ) {
    $headerString .= $key . ': ' . $value . "\r\n";
}
$params = array (
        'http' => array (
            'header' => $headerString,
            'method' => 'POST',
            'content' => $payload
        )
    );
$stream = stream_context_create ( $params );

$fp = @fopen ( 'https://'.$host.$uriPath, 'rb', false, $stream );

if (! $fp) {
    throw new Exception ( "Exception Occured" );
}
$response = @stream_get_contents ( $fp );
if ($response === false) {
    throw new Exception ( "Exception Occured" );
}
echo $response;

class AwsV4 {

    private $accessKey = null;
    private $secretKey = null;
    private $path = null;
    private $regionName = null;
    private $serviceName = null;
    private $httpMethodName = null;
    private $queryParametes = array ();
    private $awsHeaders = array ();
    private $payload = "";

    private $HMACAlgorithm = "AWS4-HMAC-SHA256";
    private $aws4Request = "aws4_request";
    private $strSignedHeader = null;
    private $xAmzDate = null;
    private $currentDate = null;

    public function __construct($accessKey, $secretKey) {
        $this->accessKey = $accessKey;
        $this->secretKey = $secretKey;
        $this->xAmzDate = $this->getTimeStamp ();
        $this->currentDate = $this->getDate ();
    }

    function setPath($path) {
        $this->path = $path;
    }

    function setServiceName($serviceName) {
        $this->serviceName = $serviceName;
    }

    function setRegionName($regionName) {
        $this->regionName = $regionName;
    }

    function setPayload($payload) {
        $this->payload = $payload;
    }

    function setRequestMethod($method) {
        $this->httpMethodName = $method;
    }

    function addHeader($headerName, $headerValue) {
        $this->awsHeaders [$headerName] = $headerValue;
    }

    private function prepareCanonicalRequest() {
        $canonicalURL = "";
        $canonicalURL .= $this->httpMethodName . "\n";
        $canonicalURL .= $this->path . "\n" . "\n";
        $signedHeaders = '';
        foreach ( $this->awsHeaders as $key => $value ) {
            $signedHeaders .= $key . ";";
            $canonicalURL .= $key . ":" . $value . "\n";
        }
        $canonicalURL .= "\n";
        $this->strSignedHeader = substr ( $signedHeaders, 0, - 1 );
        $canonicalURL .= $this->strSignedHeader . "\n";
        $canonicalURL .= $this->generateHex ( $this->payload );
        return $canonicalURL;
    }

    private function prepareStringToSign($canonicalURL) {
        $stringToSign = '';
        $stringToSign .= $this->HMACAlgorithm . "\n";
        $stringToSign .= $this->xAmzDate . "\n";
        $stringToSign .= $this->currentDate . "/" . $this->regionName . "/" . $this->serviceName . "/" . $this->aws4Request . "\n";
        $stringToSign .= $this->generateHex ( $canonicalURL );
        return $stringToSign;
    }

    private function calculateSignature($stringToSign) {
        $signatureKey = $this->getSignatureKey ( $this->secretKey, $this->currentDate, $this->regionName, $this->serviceName );
        $signature = hash_hmac ( "sha256", $stringToSign, $signatureKey, true );
        $strHexSignature = strtolower ( bin2hex ( $signature ) );
        return $strHexSignature;
    }

    public function getHeaders() {
        $this->awsHeaders ['x-amz-date'] = $this->xAmzDate;
        ksort ( $this->awsHeaders );

        // Step 1: CREATE A CANONICAL REQUEST
        $canonicalURL = $this->prepareCanonicalRequest ();

        // Step 2: CREATE THE STRING TO SIGN
        $stringToSign = $this->prepareStringToSign ( $canonicalURL );

        // Step 3: CALCULATE THE SIGNATURE
        $signature = $this->calculateSignature ( $stringToSign );

        // Step 4: CALCULATE AUTHORIZATION HEADER
        if ($signature) {
            $this->awsHeaders ['Authorization'] = $this->buildAuthorizationString ( $signature );
            return $this->awsHeaders;
        }
    }

    private function buildAuthorizationString($strSignature) {
        return $this->HMACAlgorithm . " " . "Credential=" . $this->accessKey . "/" . $this->getDate () . "/" . $this->regionName . "/" . $this->serviceName . "/" . $this->aws4Request . "," . "SignedHeaders=" . $this->strSignedHeader . "," . "Signature=" . $strSignature;
    }

    private function generateHex($data) {
        return strtolower ( bin2hex ( hash ( "sha256", $data, true ) ) );
    }

    private function getSignatureKey($key, $date, $regionName, $serviceName) {
        $kSecret = "AWS4" . $key;
        $kDate = hash_hmac ( "sha256", $date, $kSecret, true );
        $kRegion = hash_hmac ( "sha256", $regionName, $kDate, true );
        $kService = hash_hmac ( "sha256", $serviceName, $kRegion, true );
        $kSigning = hash_hmac ( "sha256", $this->aws4Request, $kService, true );

        return $kSigning;
    }

    private function getTimeStamp() {
        return gmdate ( "Ymd\THis\Z" );
    }

    private function getDate() {
        return gmdate ( "Ymd" );
    }
}
?>

2. Docker 関連ファイルの作成

macOS Sonoma には PHP 環境はありません。asdf 等で作ってもよいのですが、今回以外の用途で PHP を使う予定はなさそうなので、前述の get_image_info.php は Docker で動かすことにしました。

Dockerfile はこのようにしました。

FROM php:8.3-cli

RUN apt -y update && \
    apt install -y jq

WORKDIR /work

COPY get_image_info.php get_image_info.php
COPY asins.txt asins.txt

COPY entrypoint.bash entrypoint.bash
RUN chmod +x entrypoint.bash

ENTRYPOINT ["/work/entrypoint.bash"]

COPY している asins.txt は 1 行 1 ASIN のテキストファイルです。# で始まる行は、次で説明する entrypoint.bash 内でスキップされます。

4003115015
4022645237
#B000YZN92W is not found
B092ZCGHGW

同様に COPY している entrypoint.bash はこんな内容です。このスクリプトは get_image_info.php のラッパーです。レスポンスを少し簡素化して、data/amazon_items/<ASIN>.json に保存します。

#!/bin/bash

set -euo pipefail

# Usage:
# $ entrypoint.bash <ASIN>
# $ entrypoint.bash -f <ASIN LIST FILE>

if [ -z "$PARTNER_TAG" ]; then
  echo 'required $PARTNER_TAG'
  exit 1
fi

if [ -z "$AMAZON_ACCESS_KEY" ]; then
  echo 'required $AMAZON_ACCESS_KEY'
  exit 1
fi

if [ -z "$AMAZON_SECRET_KEY" ]; then
  echo 'required $AMAZON_SECRET_KEY'
  exit 1
fi

if [ -z "$RESOURCE" ]; then
  echo 'required $RESOURCE'
  exit 1
fi

if [ "$1" = "-f" ]; then
  ASIN_LIST=$(grep -v '^#' "$2")
else
  ASIN_LIST="$1"
fi

for ASIN in $ASIN_LIST; do
  OUTPUT_JSON_PATH=/work/amazon_items/${ASIN}.json

  if [ -r "$OUTPUT_JSON_PATH" ]; then
    echo "$OUTPUT_JSON_PATH is already exist."
    continue
  fi

  echo "Getting ASIN: $ASIN"
  set +e
  RESULT=$(ASIN=$ASIN php get_image_info.php)
  EXIT_CODE=$?
  set -e
  echo $RESULT

  if [ "$EXIT_CODE" != 0 ]; then
    exit $EXIT_CODE
  fi

  if [ "$(echo $RESULT | jq -r '.Errors')" = "null" ]; then
    echo $RESULT | jq '.ItemsResult.Items[0]' > $OUTPUT_JSON_PATH
    echo "Created $OUTPUT_JSON_PATH"
    echo
  fi

  sleep 1
done

簡単に実行できるように compose.yml も作ります。秘匿情報である AMAZON_ACCESS_KEYAMAZON_SECRET_KEY、今回の説明ではやんわり隠したい PARTNER_TAG を、環境変数経由で Docker コンテナに注入します。

services:
  amazon-affiliate:
    build: .
    image: amazon-affiliate
    container_name: amazon-affiliate
    environment:
      - AMAZON_ACCESS_KEY=$AMAZON_ACCESS_KEY
      - AMAZON_SECRET_KEY=$AMAZON_SECRET_KEY
      - PARTNER_TAG=$PARTNER_TAG
      - RESOURCE=Images.Primary.Large
    volumes:
      - ../data/amazon_items:/work/amazon_items

こんな .envrc を作って、direnv 経由で環境変数を注入していきます。

export AMAZON_ACCESS_KEY="YOUR_AMAZON_ACCESS_KEY"
export AMAZON_SECRET_KEY="YOUR_AMAZON_SECRET_KEY"
export PARTNER_TAG="YOUR_PARTNER_TAG"

get_image_info.php を実行できるようになりました。

# ASIN を 1 つ指定すると、../data/amazon_items/<ASIN>.json が作られる
$ docker compose run --rm --build amazon-affiliate 4003115015

# asins.txt を指定すると、../data/amazon_items/*.json が作られる
$ docker compose run --rm --build amazon-affiliate -f asins.txt

3. Makefile の作成

もっと簡単に実行するために Makefile も作っていきます。

ルートの Makefile です。

DIRENV := direnv
MAKE := make

data/amazon_items/%.json:
	@$(DIRENV) exec amazon_affiliate $(MAKE) -w -C amazon_affiliate ASIN=$(basename $(@F)) amazon_item

.PHONY: amazon_items
amazon_items:
	@$(DIRENV) exec amazon_affiliate $(MAKE) -w -C amazon_affiliate amazon_items

.PHONY: clean_amazon_items
clean_amazon_items:
	@$(MAKE) -w -C amazon_affiliate clean

amazon_affiliate/Makefile です。

DOCKER_COMPOSE := docker compose

ASINS_FILE := asins.txt

.PHONY: amazon_item
amazon_item:
	$(DOCKER_COMPOSE) run --rm --build amazon-affiliate $(ASIN)

.PHONY: amazon_items
amazon_items:
	$(DOCKER_COMPOSE) run --rm --build amazon-affiliate -f $(ASINS_FILE)

.PHONY: clean
clean:
	for i in $$(cat $(ASINS_FILE)); do \
		$(RM) ../data/amazon_items/$${i}.json; \
	done

ルートディレクトリで且つ、docker compose を意識せずに使えるようになりました。

# data/amazon_items/4003115015.json を作成する
$ make data/amazon_items/4003115015.json

# amazon_affiliate/asins.txt を読み込んで、data/amazon_items/*.json を作成する
$ make amazon_items

まとめ

Product Advertising API (PA-API) を使って、Amazon の商品画像 URL を取得する方法をまとめました。

次回は data/amazon_items/*.json を参照する Hugo の Amazon ショートコードを紹介する予定です。

追記(2024-03-12):
[2024-03-12-1] で紹介しました。

補足

商品画像自体を自分のサーバにアップロードするのは規約違反

実は最初は商品画像を全部ダウンロードして、このブログの /images/amazon/ 以下に数日間置いていました。商品画像 URL が変わり得えたら嫌だなと思ったからです。

🔗 Amazon アソシエイト > ヘルプ > はじめに > 適切な行為

・商品画像をダウンロードして利用する。
商品画像を保存後、任意のAmazonではないサーバーにアップしなおしての利用はできません。Amazonが提供している商品画像URLを指定する形でご利用ください

あとからこの規約に気づいて、今回の実装に変更しました。

仮に商品画像 URL が変わってしまったら、$ make clean_amazon_items amazon_itemsdata/amazon_items/*.json を作り直せば良いと割り切りました。

Rate Limit が結構厳しい

今回の asins.txt は 368 行あります。途中、PA-API のレスポンスがタイムアウトしたり、エラーになったことが多々ありました。

🔗 API Rates · Product Advertising API 5.0

As soon as you create your Product Advertising API 5.0 credentials, you are allowed an initial usage limit up to a maximum of one request per second (one TPS) and a cumulative daily maximum of 8640 requests per day (8640 TPD) for the first 30-day period. This will help you begin your integration with the API, test it out, and start building links and referring products to your readers.

1 秒間に実行できる API コールの最大値は 1 回で、1 日あたりでは 60 * 60 * 24 = 86,400 回ではなく 8,640 回とのこと。

Your PA API usage limit will be adjusted based on your shipped item revenue. Your account will earn a usage limit of one TPD for every five cents or one TPS (up to a maximum of ten TPS) for every $4320 of shipped item revenue generated via the use of Product Advertising API 5.0 for shipments in the previous 30-day period.

出荷商品収益によって Rate Limit が緩和されるようだが、このブログには関係なさそう。😅