2024/01/01 から、Amazon アソシエイトを使った Amazon の商品画像表示が出来なくなりました。
こちらのブログ記事で知りました。Product Advertising API (PA-API) を使う必要があるようです。
🔗 2024年1月1日からAmazonアフィリンクの画像が表示できなくなりそうなので、対応をまとめた: わたしが知らないスゴ本は、きっとあなたが読んでいる
本ブログも Amazon アソシエイトで商品画像を表示していたので、年明けから Amazon の商品画像がすべてリンク切れになりました。
仕方がないので、Product Advertising API (PA-API) 5.0 を使って、過去記事で参照している Amazon の商品画像情報を全て取得し、ローカルで保持することにしました。
このように進めました。
- Product Advertising API の認証キーを取得する
- 自分のトラッキング ID をメモしておく
- API テスト実行ツールを使って、PHP の Code snippet を取得する
- PHP の Code snippet を Docker で動かして、1 つの商品画像情報を 1 つの json ファイルとして保存する
1
〜 3
は前述のブログで解説されているので、割愛します。
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
- ASIN を指定する。例:
- 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_KEY
と AMAZON_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_items
で data/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 が緩和されるようだが、このブログには関係なさそう。😅