Amazonの商品検索ウィジェットが文字化けするので自作してみた part3
元々はAmazonの商品検索ウィジェットが文字化けするので仕方なく自分用に作ったPHPスクリプトですが、実際使って頂いた方からのフィードバックがあると嬉しいものですね。
SearchIndexの指定ができると便利、とのご要望を頂いたので機能追加してみました。
自作Amazon商品検索ウィジェットの追加機能
- data-searchindex属性でAmazonのSearchIndexを指定できるように変更。
- data-browsenodeid属性でAmazonのBrowseNodeIdを指定できるように変更。
- data-update属性を追加し、キャッシュを強制的に更新できる機能を追加。
あと細々とした修正とCSSのイケてない部分の修正もちょろっとしてあります。
自作Amazon商品検索ウィジェットの使い方
HTML部分
■すべてのカテゴリーから検索する例
<div class="amazon-search" data-keywords="PHP"></div>
■SearchIndexを指定する例
<div class="amazon-search" data-keywords="PHP" data-searchindex="Books"></div>
■BrowseNodeIdを指定する例
<div class="amazon-search" data-keywords="PHP" data-browsenodeid="465392"></div>
■キャッシュを強制更新する例
<div class="amazon-search" data-keywords="PHP" data-update="1"></div>
data-searchindex / data-browsenodeid / data-update はそれぞれ単独でも組み合わせても使用可能です。
JavaScriptとCSSの部分
<script src="amazon_search.js" async></script>
<link rel="stylesheet" href="amazon_search.css">
外部ファイル化しましたので上のような書き方をしていますが、従来どおりJavaScriptとCSSをテンプレート等に貼り付けても構いません。
実行例
AmazonのSearchIndexとブラウズノードについて
使用できるSearchIndexの一覧は下記の PAAPI v5 マニュアルに記載があります。
https://webservices.amazon.com/paapi5/documentation/locale-reference/japan.html
ブラウズノードの一覧についてはAPIで取得できなくもないのですが、膨大な量になるため、下記のAmazonページから探したほうが早いかと思います。
https://www.amazon.co.jp/gp/site-directory
このサイトマップから、例えば「本」を選んだとします。
するとアドレスバーに
https://www.amazon.co.jp/本-通販/b?ie=UTF8&node=465392&ref_=sd_allcat_jb
というような表記がされていると思いますが、node=以降にかかれている赤字部分がブラウズノードIDです。
SearchIndexではざっくりとしたジャンル指定しか出来ませんので、より細かい指定がしたい場合にはブラウズノードIDを指定すると良いでしょう。
ダウンロード先
長々しい記事だと読むのも大変だと思い、PHPスクリプト、JavaScript、CSSをZIPにまとめてアップしておきました。ご自由に改変してお使いください。
少なくともPHP上部に書かれているアクセスキーやアソシエイトタグの変更は必須です。
https://blog.ver001.com/uploads/amazon_search_20200529.zip
ソースコード
ダウンロードや解凍は面倒なんじゃあ!とか、WordPressの管理ページで貼り付けるからコードそのままアップしてくれたほうが楽!という方のためにソースも貼り付けておきます。ちょっと長いので、不要な方はスルー推奨。
PHPスクリプト (amazon_search.php)
<?php
/*****************************************************************************************
* Amazon商品検索ウィジェットっぽいことをするPHPスクリプト
*
*****************************************************************************************
* 使用例:
* <div class="amazon-search" data-keywords="PHP" data-searchindex="Books"></div>
*****************************************************************************************/
///////////////////////////////////////////////////////////////////////////////////////////
// 設定部分
///////////////////////////////////////////////////////////////////////////////////////////
//Amazon PAAPIの設定
$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';
///////////////////////////////////////////////////////////////////////////////////////////
//特定URL(運営サイト)からのアクセスしか受け付けないようにしたほうが良いと思う
//if (substr_compare($_SERVER['HTTP_REFERER'], 'https://example.com', 0, 19) !== 0) exit();
//PHPスクリプトを別ドメインに置く場合はAccess-Control-Allow-Originを忘れずに
//header('Access-Control-Allow-Origin:https://example.com');
if (!isset($_GET['keywords'])) exit();
$keywords = $_GET['keywords']; //←必要に応じてサニタイズ処理を
//キャッシュ用データベースの準備
$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 ( (isset($_GET['update']) && $_GET['update'] == '1') ||
($row['update_date'] <= date("Y-m-d H:i:s", strtotime($cfg['CACHE_TERM'])))) {
//キャッシュがあったとしても一定期間以上経っていた場合は更新する
//2020-05-28 また、update=1が指定されていた場合は期間に関わらずキャッシュを更新する
$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>';
echo $html;
}
/************************************************
* Amazon PAAPIから取得したデータをDBへ格納
*
************************************************/
function setAmazonItem($pdo, $keywords, $aws)
{
if (!isset($aws->SearchResult->Items)) return;
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"'.
' ]';
if (isset($_GET['searchindex'])) { //2020-05-28 SearchIndex対応
$payload .= ',"SearchIndex":"'.$_GET['searchindex'].'"';
}
if (isset($_GET['browsenodeid'])) { //2020-05-28 BrowseNodeId対応
$payload .= ',"BrowseNodeId":"'.$_GET['browsenodeid'].'"';
}
$payload .= '}';
$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" );
}
}
?>
JavaScript (amazon_search.js)
document.querySelectorAll('.amazon-search').forEach(e => {
let xhr = new XMLHttpRequest();
let url = 'amazon_search.php?keywords=' + encodeURIComponent(e.dataset.keywords);
if (e.dataset.update !== undefined) url += '&update=' + e.dataset.update;
if (e.dataset.searchindex !== undefined) url += '&searchindex=' + e.dataset.searchindex;
if (e.dataset.browsenodeid !== undefined) url += '&browsenodeid=' + e.dataset.browsenodeid;
xhr.open('GET', url, true);
xhr.onload = function () { e.innerHTML = this.responseText; }
xhr.send();
});
CSS (amazon_search.css)
div.amazon_search { border:1px solid #cccccc;border-top:3px solid #ff8000;border-radius:4px;padding:4px 1em 0 1em; }
div.amazon_search img:nth-child(1) { display:inline-block;height:20px;border:0;padding:0;vertical-align:middle; }
div.amazon_search a:nth-of-type(2) { display:inline-block;font-size:9pt;text-decoration:none;margin:0 0 14px; }
div.amazon_search div { position:relative;display:inline-block;margin:0 6px 2px 0; }
div.amazon_search div img:nth-child(1) { width:120px;height:120px;object-fit:cover; }
div.amazon_search div:hover p { transform:scale(1.2);transition-duration:0.3s; }
div.amazon_search div:hover img { transform:scale(1.2);transition-duration:0.3s; }
div.amazon_search div p { position:absolute;width:120px; }
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; }
div.amazon_search div p { bottom:0px;text-align:right;font-weight:bold;color:#cc0000;font-size:8pt;text-shadow:2px 2px 2px #808080; }
div.amazon_search footer { font-size:0.8em;text-align:right; }
まとめ
前々からAmazon謹製のウィジェットにお世話になりつつも、カテゴリの指定くらいさせて欲しいよなぁー、自分で作ろうかなぁーとは思いつつも重い腰が上がりませんでした。
しかし今回、ずいぶん長期間に渡って不具合が放置されているように感じます。ついさっき(2020年5月末)使ってみてもやはり日本語検索で文字化けしていましたし。
自作しても、本家のウィジェットが動くようになったらすぐ戻すことになるだろう、と思っていましたが、こう、長期間本家のウィジェットが使えないとなると、作っておいて良かった感があります。カテゴリ指定もできるようになりましたし。
また、自分用の備忘録のつもりでアップしたスクリプトでしたが、ありがたいことに、お使い頂いた方からのバグ報告やご要望のおかげで、コードをブラッシュアップすることが出来ました。
あぁぁ、そうそう!大事なことを忘れていました。
コードをスリムにするために外部サイトからのブロック等、エラー処理をサボっている部分がありますので、本格運用する際はPHPのコメント欄にあるとおり、アクセス元(HTTP_REFERER)のチェック処理は入れておいたほうが良いと思います。
それでは、拙いスクリプトではございますが、どこかの誰かのお役に立てれば幸いです。