Amazonの商品検索ウィジェットが文字化けするので自作してみた
表題のとおりなのですが、最近Amazonの商品検索ウィジェットの調子悪くありませんか?
Amazonアソシエイトを使っている方ならお馴染みだと思うのですが、↓こういうやつです。
Amazonの商品検索ウィジェットが文字化けを繰り返す
先月(2020年4月)の初旬にも同じ不具合があったのですが、また現在も(2020年5月)Amazonの商品検索ウィジェットに不具合が生じているようです。
具体的には↓このとおり、日本語を含む商品名を検索すると文字化けする不具合。
文字化けというか、エンコードミスってるだけだとは思いますが、先月初旬の不具合は数日で復旧され、その後数週間は問題なかったのですが、4月下旬頃、今度は一切の商品検索ができなくなる不具合も発生しました。
どちらの不具合でもAmazonアソシエイトのサポートへ報告は入れており、数日で修正されました。
そして今回(5月初旬)また商品検索ウィジェットで日本語が文字化けする不具合が発生したので、数日待ってみたのですが、直りそうもないため、3度目となって恐縮ですが、サポートへ報告を入れておきました。
あ、もちろん、「ウィジェットがバグってるやろがぁぁ!!なんとかしろやぁぁ!!」みたいなクレーム入れてるわけではございませんw
コロナ騒動の関係でエンジニアも遠隔でメンテナンスしてそうだし、大変な状況でしょう。ただ、気がついていないだけの可能性もなくもなくもないしなぁー…と。
あくまで淡々と、商品検索ウィジェットで日本語の商品名が検索できない旨を伝えているだけです。
Amazonの商品検索ウィジェットが動かないと売上が結構減る
ケチくさい話で恐縮ですが、ぼくは複数のブログやサイトを運営しており、中にはAmazonの商品検索ウィジェットのおかげで、それなりに売上をあげているサイトもあります。
Amazonの商品って入れ替わりが激しくて、レビュー記事などで商品決め打ちでリンクを貼っていても気がつくとリンク切れしていることも多々あるものですから、いっそ商品の型番で検索した結果を貼り付けたほうが、常に最新情報で便利なんです。
そんなわけで、Amazonの商品検索ウィジェットが動かないのは少し困る。
ぼくが知っているだけで4月~5月にかけて3度もこのウィジェットが動かない事態が発生したので、今後も繰り返す可能性は十分あるだろうとも考えられます。
せっかくAmazonがProduct Advertising APIなんて便利なWebAPIサービスを提供してくれているのですから、じゃあ自分で作るか、ということで、作ってみました。
Amazon商品検索ウィジェットっぽいものを自作する(PHP)
御託を並べるより、コードのほうが需要あると思うので、PHPで作ったコードをそのまま貼り付けます。けっこう長いです。
<?php
/*****************************************************************************************
* 劣化版 Amazon商品検索ウィジェット
*
* 使用例:
* <script src="amazon_search.php?keywords=ほにゃらら"></script>
*****************************************************************************************/
///////////////////////////////////////////////////////////////////////////////////////////
// 設定部分
///////////////////////////////////////////////////////////////////////////////////////////
//Amazon AWSの設定
$cfg['AWSAccessKeyId'] = "[アクセスキー]";
$cfg['AWSSecretAccessKeyId'] = "[シークレットアクセスキー]";
$cfg['AssociateTag'] = "[アソシエイトタグ]";
//キャッシュ用データベース設定
$cfg['DB_FILE'] = 'amazon_search.db';
$cfg['DB_OPTION'] = array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_TIMEOUT => 20);
//キャッシュ保持日数
$cfg['CACHE_TERM'] = "-3 days";
//検索結果の上部に表示するAmazonアイコン画像
$cfg['AMAZON_ICON'] = 'assocbutt_or_amz.png';
///////////////////////////////////////////////////////////////////////////////////////////
//ここでは$_SERVER['HTTP_REFERER']をチェックする等、特定URL(運営サイト)からのアクセスしか受け付けないようにしたほうが良いかも
if (!isset($_GET['keywords'])) {
exit();
}
$keywords = $_GET['keywords']; //←必要に応じてサニタイズ処理を
//2020-05-27 UTF-8以外で渡されることを考慮して追記
$keywords = mb_convert_encoding($keywords, 'UTF-8', 'auto');
//キャッシュ用データベースの準備
$pdo = connectDB();
//キャッシュ取得または生成
genAmazonCache($pdo, $keywords);
//キャッシュから検索結果を表示
viewAmazonResult($pdo, $keywords);
/************************************************
* データベースを開く
* なければないで作る
************************************************/
function connectDB()
{
global $cfg;
//キャッシュ用のDBファイルがない場合は作る
if (!file_exists($cfg['DB_FILE'])) {
$pdo = new PDO('sqlite:'.$cfg['DB_FILE'], '', '', $cfg['DB_OPTION']);
//キャッシュ用テーブルの準備(1つはキーワードと更新日の保存用、もう1つは商品データ用)
$query = "CREATE TABLE 'amazon_search_keywords' (".
" keywords text PRIMARY KEY,".
" update_date datetime);";
$pdo->query($query);
$query = "CREATE TABLE 'amazon_search_item' (".
" keywords text,".
" asin text,".
" title text,".
" brand text,".
" model text,".
" image_s text,".
" image_m text,".
" image_l text,".
" feature text,".
" price integer);";
$pdo->query($query);
//インデックスも張っておく
$query = "CREATE INDEX amazon_search_item_index ON amazon_search_item(keywords)";
$pdo->query($query);
} else {
$pdo = new PDO('sqlite:'.$cfg['DB_FILE'], '', '', $cfg['DB_OPTION']);
}
return $pdo;
}
/************************************************
* キャッシュをチェックし、なければ(あるいは古ければ)
* Amazonからデータをひっぱってきてキャッシュを作る感じ
************************************************/
function genAmazonCache($pdo, $keywords)
{
global $cfg;
//キーワードキャッシュがあるか確認
$stmt = $pdo->prepare('SELECT update_date FROM amazon_search_keywords WHERE keywords=?');
$stmt->bindValue(1, $keywords);
$stmt->execute();
//更新フラグ
$flg_update = false;
if ($row = $stmt->fetch()) {
if ($row['update_date'] <= date("Y-m-d H:i:s", strtotime($cfg['CACHE_TERM']))) {
//キャッシュがあったとしても一定期間以上経っていた場合は更新する
$flg_update = true;
$stmt = $pdo->prepare('UPDATE amazon_search_keywords SET update_date=? WHERE keywords=?');
$stmt->bindValue(1, date('Y-m-d H:i:s'));
$stmt->bindValue(2, $keywords);
$stmt->execute();
}
} else {
$flg_update = true;
$row = array();
$row['keywords'] = $keywords;
$row['update_date'] = date('Y-m-d H:i:s');
pdoInsert($pdo, 'amazon_search_keywords', $row);
}
if ($flg_update) {
//PAAPIからJSON取得
$json = getAmazonSearchItems($keywords);
$aws = json_decode($json);
$pdo->beginTransaction();
//キャッシュのクリア
$stmt = $pdo->prepare('DELETE FROM amazon_search_item WHERE keywords=?');
$stmt->bindValue(1, $keywords);
$stmt->execute();
//キャッシュに格納
setAmazonItem($pdo, $keywords, $aws);
$pdo->commit();
}
}
/************************************************
* 検索結果を表示
*
************************************************/
function viewAmazonResult($pdo, $keywords)
{
global $cfg;
$stmt = $pdo->prepare('SELECT * FROM amazon_search_item WHERE keywords=?');
$stmt->bindValue(1, $keywords);
$stmt->execute();
$html = '';
$html .= '<div class="amazon_search">';
$search_url = 'https://www.amazon.co.jp/s/ref=as_li_ss_tl?k='.urlencode($keywords).'&tag='.$cfg['AssociateTag'];
$html .= '<a href="'.$search_url.'"><img src="'.$cfg['AMAZON_ICON'].'"></a> <a href="'.$search_url.'">'.htmlspecialchars($keywords).'</a><br>';
$html .= '<section style="display:inline-block;text-align:center;">';
while ($row = $stmt->fetch()) {
$url = 'https://www.amazon.co.jp/dp/'.htmlspecialchars($row['asin']).'/?tag='.$cfg['AssociateTag'];
$html .= '<div>';
$html .= '<a href="'.$url.'" title="'.htmlspecialchars($row['title']).'" target="_blank">';
$html .= '<img src="'.$row['image_m'].'"><br>';
$html .= '<p>'.htmlspecialchars($row['title']).'</p>';
$html .= '</a>';
$html .= '<p>'.number_format($row['price']).'円</p>';
$html .= '</div>';
}
$html .= '</section>';
$html .= '<footer><a href="'.$search_url.'">もっと見る</a></footer>';
$html .= '</div>';
$html .= '<style>';
$html .= 'div.amazon_search { border:1px solid #cccccc;border-top:3px solid #ff8000;border-radius:4px;padding:4px 1em 0 1em; }';
$html .= 'div.amazon_search img:nth-child(1) { height:20px;margin:0 0 14px;vertical-align:top;border:0; }';
$html .= 'div.amazon_search a { font-size:9pt;text-decoration:none; }';
$html .= 'div.amazon_search a:nth-child(2) { display:inline-block;vertical-align:top !important;margin-top:3px !important; }';
$html .= 'div.amazon_search div { position:relative;display:inline-block;margin:0 6px 2px 0; }';
$html .= 'div.amazon_search div img:nth-child(1) { width:120px;height:120px;object-fit:cover; }';
$html .= 'div.amazon_search div:hover p { transform:scale(1.2);transition-duration:0.3s; } ';
$html .= 'div.amazon_search div:hover img { transform:scale(1.2);transition-duration:0.3s; } ';
$html .= 'div.amazon_search div p { position:absolute;width:120px; } ';
$html .= 'div.amazon_search div a p { bottom:28px;background-color:rgba(100,100,100,0.3);color:white;margin:1px;font-size:9pt;white-space:nowrap;text-overflow:ellipsis;overflow:hidden; }';
$html .= 'div.amazon_search div p { bottom:0px;text-align:right;font-weight:bold;color:#cc0000;font-size:8pt; }';
$html .= 'div.amazon_search footer { font-size:0.8em;text-align:right; }';
$html .= '</style>';
//JavaScript用のエスケープ処理
$html = str_replace("'", "\\'", $html);
echo "document.write('".$html."');";
}
/************************************************
* Amazon PAAPIから取得したデータをDBへ格納
*
************************************************/
function setAmazonItem($pdo, $keywords, $aws)
{
foreach ($aws->SearchResult->Items as $item) {
$row_upd = array();
$row_upd['keywords'] = $keywords;
$row_upd['asin'] = $item->ASIN;
$row_upd['title'] = mb_substr($item->ItemInfo->Title->DisplayValue, 0, 255);
$row_upd['brand'] = @$item->ItemInfo->ByLineInfo->Brand->DisplayValue;
$row_upd['model'] = isset($item->ItemInfo->ManufactureInfo->Model) ? $item->ItemInfo->ManufactureInfo->Model->Label : '';
//画像
$row_upd['image_s'] = $item->Images->Primary->Small->URL;
$row_upd['image_m'] = $item->Images->Primary->Medium->URL;
$row_upd['image_l'] = $item->Images->Primary->Large->URL;
if ($row_upd['image_s'] == '') $row_upd[':image_s'] = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
if ($row_upd['image_m'] == '') $row_upd[':image_m'] = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
if ($row_upd['image_l'] == '') $row_upd[':image_l'] = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
$row_upd['price'] = @$item->Offers->Summaries[0]->LowestPrice->Amount;
pdoInsert($pdo, "amazon_search_item", $row_upd);
}
}
/************************************************
* 連想配列からINSERT文を実行する
* (何度も同じ項目名を書くのが面倒なため)
************************************************/
function pdoInsert($pdo, $tablename, $row)
{
$keys1 = '';
$keys2 = '';
foreach ( $row as $key => $value ) {
$keys1 .= $key . ','; //項目名を列挙
$keys2 .= ':' . $key . ','; //プレースホルダを列挙
}
$keys1 = substr($keys1, 0, -1);
$keys2 = substr($keys2, 0, -1);
$stmt = $pdo->prepare('INSERT INTO '.$tablename.' ('.$keys1.') VALUES ('.$keys2.')');
$stmt->execute($row);
}
/************************************************
* ここから下はAmazon PAAPI 5.0のScratchpadで生成されるコードほぼそのまんまです
* https://webservices.amazon.co.jp/paapi5/scratchpad/index.html
************************************************/
function getAmazonSearchItems($keywords) {
global $cfg;
$serviceName="ProductAdvertisingAPI";
$region="us-west-2";
$accessKey=$cfg['AWSAccessKeyId'];
$secretKey=$cfg['AWSSecretAccessKeyId'];
$payload="{"
." \"Keywords\": \"{$keywords}\","
." \"PartnerTag\": \"{$cfg['AssociateTag']}\","
." \"PartnerType\": \"Associates\","
." \"Marketplace\": \"www.amazon.co.jp\","
." \"Resources\": ["
." \"Images.Primary.Small\","
." \"Images.Primary.Medium\","
." \"Images.Primary.Large\","
." \"Offers.Summaries.LowestPrice\","
." \"ItemInfo.Title\","
." \"ItemInfo.ByLineInfo\","
." \"ItemInfo.ManufactureInfo\","
." \"ItemInfo.ProductInfo\""
." ]"
."}";
$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.SearchItems');
$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" );
}
return $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" );
}
}
?>
自作Amazon商品検索ウィジェットの解説
全部解説しているとアレなのでざっくりと要点だけですが、
- connectDB
PDOを使い、SQLiteのDBへ接続(なければ作成) - genAmazonCache
DB内に渡されたキーワードでの検索結果(キャッシュ)があるかないかチェック
なければ作成。あっても古ければ再生成。 - viewAmazonResult
キャッシュから検索結果を表示。
とまぁこんな流れです。
Amazon PAAPIは売上に応じてリクエスト回数の上限があるため、毎回商品検索するわけではなく、いったんSQLiteのDBに保存して(デフォルトでは)3日間はキャッシュから表示するようにしました。 (XMLとかにしても良かったのですがPHP5以降ならSQLiteがデフォルトで組み込まれてますし早いですしね)
なにぶん自分用に作ったので非常に…その…カスタマイズ性が低い感じですが、スクリプト上部の「// 設定部分」をご覧いただければわかるとおり、申し訳程度の設定ができるようにしてありますので、コピペしてPHPファイルに保存するだけでも一応使える…ハズ。
ただ、デザインについてはほぼハードコーディングなので、viewAmazonResult関数の中をいじってもらうしかないです。
↓なにもいじらない場合はこんなデザイン。
デザインセンスなくてすみませんwww
あと、PAAPIからはRating(星の数)が取得できないので、このスクリプトでも表示していません。なので「劣化版」と名乗っています。
しかしながら、文字化けしたり全く検索できないよりは良いかな…ということで順次自作スクリプトへ置き換えております。
大したものではございませんがコードについてはご自由にお使いください。
2020-05-27追記
コメント欄にて修正コードをご提示頂きましたのでサンプルに追記しておきました。
$keywords = mb_convert_encoding($keywords, 'UTF-8', 'auto');
そうなんですよねぇ。JavaScript側の書きやすさを優先して、
* 使用例:
* <script src="amazon_search.php?keywords=ほにゃらら"></script>
なんて書いちゃってますけど、本来URLエンコードしなきゃいけない。(一応今のコードでもPHPがUTF8で保存されていて、リンク元もUTF8なら動作はする、はず)
きちんと修正するにはコードが長くなりそうなので詳細は別記事で書くことにしました。