Amazon simple Api for PHP
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

531 lines
22 KiB

<?php
define('CACHE_DIR','/web/cache/amazon_api');
require_once(__DIR__."/awsv4.class.php");
require_once(__DIR__."/class.simplecache.php");
/** Retrieving advertizements via the Amazon product API
* @package Asap
* @class AmazonAds
* @author Andreas Itzchak Rehberg (Izzy)
*/
class AmazonAds {
/** Are we in SSL mode?
* If so, we want to have Amazon image URLs adjusted accordingly.
* @class AmazonAds
* @attribute protected bool ssl
*/
protected $ssl = false;
/** URL of the directory we serve images from
* This is for more privacy of your visitors. Default is NULL, i.e. this is disabled.
* @class AmazonAds
* @attribute protected string imgBaseURL
* @see self::setImgBase
*/
protected $imgBaseURL = NULL;
/** Name of the directory to save image files to
* @class AmazonAds
* @attribute protected string imgCacheName
* @see self::setImgBase
*/
protected $imgCacheName = NULL;
/** Public Access Key (provided per account by Amazon Partner Net)
* @class AmazonAds
* @attribute protected string pubKey
*/
protected $pubKey = NULL;
/** Private Secret Key (provided per account by Amazon Partner Net)
* @class AmazonAds
* @attribute protected string privKey
*/
protected $privKey = NULL;
/** Associate key integrated into links (multiple of them can be created per account by Amazon Partner Net)
* @class AmazonAds
* @attribute protected string associateTag
*/
protected $associateTag = NULL;
/** Local site the PartnerNet account is bound to and links shall point to
* @class AmazonAds
* @attribute protected string localSite
*/
protected $localSite = 'de';
/** Language preference for returned content
* @class AmazonAds
* @attribute protected string langPref
* @see this::setLangPref
*/
protected $langPref = '';
/** RegionName we refer to – e.g. 'us-east-1', 'eu-west-1'
* @class AmazonAds
* @attribute protected string regionName
* @see https://webservices.amazon.com/paapi5/documentation/common-request-parameters.html#host-and-region
*/
protected $regionName = 'eu-west-1';
/** Did we hit some API Limit?
* If an API limit was hit (usually by getItemsByKeyword() making multiple calls for multiple SearchIndexes),
* subsequent calls will have no effect. In that case, getItemByKeyword() will stick to cache for those.
* @class AmazonAds
* @attribute protected boolean ApiLimitHit
* @see https://webservices.amazon.com/paapi5/documentation/troubleshooting/api-rates.html
*/
protected $ApiLimitHit = false;
/** Reset the block on ApiLimit
* @class AmazonAds
* @method resetApiLimit
* @see ApiLimitHit
*/
public function resetApiLimit() {
$this->ApiLimitHit = false;
}
/** Activate privacy mode: set the ImgBase
* Note: Setting this to NULL will disable local images. Otherwise, $urlbase must match
* CACHE_DIR.'/'.$cachename (which must exist and be writeable by the web server; use e.g. symlinks).
* @class AmazonAds
* @method setImgBase
* @param string urlbase Base name of the URL images can be retrieved from (e.g. '/shared/images/asap')
* @param optional string cachename Name to use for the images to store. This will be appended to the CACHE_DIR constant. Default: 'asap'
* @return bool success
*/
public function setImgBase($urlbase,$cachename='asap') {
if ( empty($urlbase) || $urlbase == NULL ) {
$this->imgBaseURL = NULL;
return true;
} elseif ( empty($cachename) || !is_dir(CACHE_DIR.'/'.$cachename) ) {
trigger_error('The specified cache directory does not exist! Switching off local image mode.', E_USER_NOTICE);
$this->imgBaseURL = NULL;
return false;
} else {
$this->imgBaseURL = $urlbase;
$this->imgCacheName = CACHE_DIR.'/'.$cachename;
return true;
}
}
/** Initialize the class with your credentials
* @constructor AmazonAds
* @param string public Public key to be used
* @param string private Private string to be used
* @param string associate_tag Amazon Partner ID
* @param optional string local_site Amazon site to query (e.g. 'com','de', …), defaults to 'de'
*/
public function __construct($public, $private, $associate_tag, $local_site='de') {
$this->pubKey = $public;
$this->privKey = $private;
$this->associateTag = $associate_tag;
$this->localSite = $local_site;
$this->cache = new SimpleCache();
if ( isset($_SERVER['HTTPS']) ) $this->ssl = TRUE;
else $this->ssl = FALSE;
}
/** SearchIndex supports multiple languages to deliver content in. Set the preferred language.
* This is only needed if you want content in a language other than the default.
* For localSite DE (Germany), the default would be de_DE – but e.g. en_GB is also supported.
* See: https://webservices.amazon.com/paapi5/documentation/locale-reference.html#topics
* @class AmazonAds
* @method setLangPref
* @param string lang language code in the format xx_XX (examples: de_DE, en_GB)
* @return bool success
*/
public function setLangPref($lang) {
if ( empty($lang) || preg_match('!^[a-z]{2}_[A-Z]{2}$!',$lang) ) {
$this->langPref = $lang;
return true;
} else {
trigger_error("The specified language pref '$lang' is invalid, must follow the pattern 'xx_XX' or be empty.", E_USER_NOTICE);
return false;
}
}
/** set the regionName
* @class AmazonAds
* @method setRegionName
* @param string region e.g. 'us-east-1', 'eu-west-1'
* @todo safe-guard to only accept valid regions
* @todo getRegionName
* @see https://webservices.amazon.com/paapi5/documentation/common-request-parameters.html#host-and-region
*/
public function setRegionName($region) {
$this->regionName = region;
}
/** Override auto-SSL setting
* By default, the constructor checks the current connection and matches SSL
* mode (to update image URLs accordingly). Here you can override this.
* @class AmazonAds
* @method setSSL
* @param boolean useSSL
*/
public function setSSL($ssl) {
$this->ssl = (bool) $ssl;
}
/** Get an image file from Amazon and save it locally
* @class AmazonAds
* @method protected downloadImage
* @param str url URL to download
* @param str asin ASIN of the product (used as filename, extension added)
* @return bool success
*/
protected function downloadImage($url,$asin) {
if ( empty($url) || empty($asin) ) return false;
$ext = preg_replace('!.*\.([^\.]+)$!','\1',$url);
$localname = $this->imgCacheName."/${asin}.${ext}";
if ( file_exists($localname) ) return true;
if ( file_put_contents($localname, file_get_contents($url)) !== FALSE ) return true;
return false;
}
/** Load from cache if available
* @class AmazonAds
* @method protected getCache
* @param string cachename Name of the cache object
* @param ref string content Where to place the content in
* @return int cacheAge (UnixTimeStamp; 0 if no cache)
*/
protected function getCache($cachename,&$content) {
$age = $this->cache->getCacheUnixTime($cachename);
if ( $age>0 && time() - $age < 86400 ) { // valid to use
$this->cache->getCache($content,$cachename);
}
return $age;
}
/** Get a basic ItemRequest object with standard parameters set
* @class AmazonAds
* @method protected getRequestItemBase
* @brief For additional properties, refer to: https://webservices.amazon.com/paapi5/documentation/search-items.html#resources-parameter
*/
protected function getRequestItemBase() {
$item = new stdClass();
$item->PartnerType = 'Associates';
$item->PartnerTag = $this->associateTag;
if ( !empty($this->langPref) ) $item->LanguagesOfPreference = [$this->langPref];
return $item;
}
/** Send signed request to PA API and return results
* @class AmazonAds
* @method protected signedRequest
* @param object ItemRequest class with the public properties PartnerType, PartnerTag, Resources plus search-specific tags like (Keywords, SearchIndex), (ItemIds, ItemIdType), see this::getRequestItemBase
* @param optional string path API path. Valid settings: '/paapi5/getbrowsenodes', '/paapi5/getitems', '/paapi5/getvariations', '/paapi5/searchitems' (the last being the default here)
* @return mixed false on error, else json response
* @todo evaluate $response::Errors if $response::ItemResults is not set or $response::ItemResuls::Items is empty
*/
protected function signedRequest($ItemRequest,$path='/paapi5/searchitems') {
if($this->localSite == 'jp') {
$host = "ecs.amazonaws.".$this->localSite;
} else {
$host = "webservices.amazon.".$this->localSite; // must be in small case
}
switch($path) {
case '/paapi5/getbrowsenodes': $amzTarget = 'com.amazon.paapi5.v1.ProductAdvertisingAPIv1.GetBrowseNodes'; break;
case '/paapi5/getitems': $amzTarget = 'com.amazon.paapi5.v1.ProductAdvertisingAPIv1.GetItems'; break;
case '/paapi5/getvariations': $amzTarget = 'com.amazon.paapi5.v1.ProductAdvertisingAPIv1.GetVariations'; break;
case '/paapi5/searchitems':
default: $amzTarget = 'com.amazon.paapi5.v1.ProductAdvertisingAPIv1.SearchItems'; break;
}
$payload = json_encode ($ItemRequest);
$awsv4 = new awsv4($this->pubKey,$this->privKey); // a new instance for each request due to e.g. x-amz-date header
$awsv4->setRegionName($this->regionName);
$awsv4->setServiceName("ProductAdvertisingAPI");
$awsv4->setPath ($path);
$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', $amzTarget);
$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 );
if ( ! $response = @file_get_contents('https://'.$host.$path, false, $stream) ) {
trigger_error("Error connecting to '${host}${path}', got '".$http_response_header[0]."'!", E_USER_NOTICE);
if ( in_array(substr($http_response_header[0],9,3), ['401','429']) ) $this->ApiLimitHit = true;
elseif ( substr($http_response_header[0],9,3) == '400' ) trigger_error( print_r($ItemRequest,true), E_USER_NOTICE);
return false;
}
if ( empty($response) ) {
trigger_error("Got empty response from '$host.$path' (response: false)!", E_USER_NOTICE);
}
return $response;
}
/** Return details of a product searched by ASIN
* @class AmazonAds
* @method getItemByAsin
* @param string $asin_code ASIN code of the product to search (comma-separated list)
* @param optional string $response_group Response-Group. Ignored, just kept for backwards compatibility to PA API 4 calls.
* @return array SearchResults (or empty array if none): int cachedate (UnixTime), array[0..n] of array of strings asin,title,url,img,price
*/
public function getItemByAsin($asin, $response_group='') {
$json = '';
if ( empty($this->langPref) ) $cachename = "${asin}_".$this->localSite;
else $cachename = "${asin}_".substr($this->langPref,0,2);
$age = $this->getCache($cachename,$json);
if ( empty($json) ) { // cache was either invalid or empty
$ItemRequest = $this->getRequestItemBase();
if ( is_array($asin) ) $ItemRequest->ItemIds = $asin;
else $ItemRequest->ItemIds = explode(',',$asin);
$ItemRequest->ItemIdType = 'ASIN';
$ItemRequest->Resources = ["Images.Primary.Medium","ItemInfo.Title","Offers.Listings.Price"];
$json = $this->signedRequest($ItemRequest,'/paapi5/getitems');
if ( !empty($json) ) {
$this->cache->setCache($json,$cachename);
}
$age = time();
}
if ( empty($json) ) return [];
$res = json_decode($json);
$cachedate = date('Y-m-d H:i',$age);
$resItems = count($res->ItemsResult->Items);
$items = [];
for ($i=0; $i<$resItems; ++$i) {
$iasin = trim($res->ItemsResult->Items[$i]->ASIN);
if (isset($res->ItemsResult->Items[$i]->ItemInfo->Title->DisplayValue)) $title = trim($res->ItemsResult->Items[$i]->ItemInfo->Title->DisplayValue); else continue;
if (isset($res->ItemsResult->Items[$i]->DetailPageURL)) $url = trim($res->ItemsResult->Items[$i]->DetailPageURL); else continue;
isset($res->ItemsResult->Items[$i]->Images->Primary->Medium->URL) ? $img = trim($res->ItemsResult->Items[$i]->Images->Primary->Medium->URL) : $img = '';
if ( $this->ssl ) $img = str_replace('http://ecx.images-amazon.com/','https://images-na.ssl-images-amazon.com/',$img);
if ( $this->imgBaseURL !== NULL ) { // share images from our server, so cache them
if ( $this->downloadImage($img,$iasin) ) $img = $this->imgBaseURL . "/${iasin}" . preg_replace('!.*(\.[^\.]+)$!','\1',$img);
}
isset($res->ItemsResult->Items[$i]->Offers->Listings[0]->Price->DisplayAmount) ? $price = trim($res->ItemsResult->Items[$i]->Offers->Listings[0]->Price->DisplayAmount) : $price = '';
$items[] = [
'asin' => $iasin,
'title' => preg_replace('!([A-z],)([A-z])!','\1&#8203;\2',$title), // insert zero-width space if comma not followed by space, for auto line breaks
'url' => preg_replace('/&(?!amp;)/','&amp;',$url),
'img' => $img,
'price' => $price
];
}
return array(
'cachedate' => $cachedate,
'items' => $items
);
}
/** Remove items which are too similar.
* We certainly don't want just a list of harddisks from the same manufacturer in all sizes, for example
* @class AmazonAds
* @method removeSimilar
* @param ref array items [0..n] of array title,url,img,price
* @param optional float similarity percentage of maximum allowed similarity of the titles. Defaults to 90
*/
function removeSimilar(&$items,$similarity=90) {
$ic = count($items); $sim = array(); $pct = 0;
for ($i=0; $i<$ic; ++$i) {
for ($k=$i+1; $k<$ic; ++$k) {
$foo = similar_text($items[$i]['title'], $items[$k]['title'], $pct );
if ( $pct > $similarity ) { // too similar
if ( !in_array($i,$sim) ) $sim[] = $k;
}
}
}
if ( !empty($sim) ) { // we've got some too close matches
$sim = array_unique($sim);
foreach( $sim as $s ) {
unset ($items[$s]);
}
$items = array_merge($items);
}
}
/** Return details of a product searched by keyword.
* Wrapper to getItemByKeyword() which takes care for multiple SearchIndexes (product_types).
* **NOTE:** due to PA API limits (e.g. 1 call per second), merged results will only turn up on subsequent calls, i.e. when parts are available from cache already.
* @class AmazonAds
* @method getItemsByKeyword
* @param string $keyword space separated list of keywords to search
* @param string $product_type type of the product (comma separated list of SearchIndexes)
* @param optional int limit How many results do we want? Defaults to 3; set to "0" for all-we-can-get
* @param optional float similarity percentage of maximum allowed similarity of the titles. Defaults to 90; set to 0 to keep all
* @return array SearchResults (or empty array if none): int cachedate (UnixTime), array[0..n] of strings asin,title,url,img,price
*/
public function getItemsByKeyword($keyword, $product_type, $limit=3, $similarity=90) {
$searchIndexes = explode(',',$product_type);
$sic = count($searchIndexes);
// verify input
if ( empty($keyword) ) {
trigger_error('getItemsByKeyword: $keyword must not be empty, or we cannot perform a search', E_USER_ERROR);
return [];
}
if ( $sic == 0 ) {
trigger_error('getItemsByKeyword: $product_type must not be empty, or we cannot perform a search', E_USER_ERROR);
return [];
}
// prepare for keyword-multi-search if requested
$keywords = []; $plus = ''; $many = [];
$tmp = explode(' ',$keyword);
foreach ( $tmp as $key ) {
if ( substr($key,0,1)=='+' ) $plus .= substr($key,1).' ';
else $many[] = $key;
}
unset ($tmp); // discard
if ( empty($plus) ) { // normal searchstring like "word1 word2 word3"
$keywords = explode(' ',$keyword);
} elseif ( count($many)==1 ) { // just a single word without prefix, like "+word1 +word2 word3"
$keywords[] = $plus . $many[0];
} elseif ( empty($many) ) { // all words with prefix, like "+word1 +word2 +word3"
$keywords = explode(' ',trim($plus));
} else { // we have at least one prefixed and multiple non-prefixed words, e.g. "+word1 word2 word3"
foreach ( $many as $m ) {
$keywords[] = $plus . $m;
}
}
// "ordinary" searches need no further processing
$kc = count($keywords);
if ( $sic==1 && $kc==1 ) { // "ordinary search": one index, list of keywords
return $this->getItemByKeyword($keywords[0], $product_type, $limit);
}
// Still here? OK, enter Multi-Search mode:
$items = array();
$cachedate = '9999-12-31 23:59';
// Get ads for each SearchIndex and keyword group separately, then merge results
for ($i=0; $i<$sic; ++$i) { // walk search indexes
for ( $k=0; $k<$kc; ++$k ) { // walk keyword combinations
$tmp = $this->getItemByKeyword($keywords[$k], $searchIndexes[$i], 0); // unlimited results
if ( array_key_exists('items',$tmp) && is_array($tmp['items']) ) { // no results, nothing to do
$items = array_merge( $items, $tmp['items'] );
if ( !empty($tmp['cachedate']) && $tmp['cachedate'] < $cachedate ) $cachedate = $tmp['cachedate'];
}
}
}
if ($similarity > 0) $this->removeSimilar($items, $similarity);
if ($limit < 1) { // getAll
return array(
'cachedate' => $cachedate,
'items' => $items
);
}
// Pick $limit random elements if we have more than requested
$litems = array();
if ( $limit < count($items) ) {
$ids = array_rand($items,$limit);
if ( is_array($ids) ) {
foreach ($ids as $id) $litems[] = $items[$id];
} else {
$litems[] = $items[$ids];
}
}
return array(
'cachedate' => $cachedate,
'items' => $litems
);
}
/** Return details of a product searched by keyword.
* Search is done in titles and descriptions, max 10 results are returned
* @class AmazonAds
* @method getItemByKeyword
* @param string $keyword space separated list of keywords to search
* @param string $product_type type of the product (only one SearchIndex! If more shall be queried, use getItemsByKeyword() instead). For SearchIndexes valid to your country, see https://webservices.amazon.com/paapi5/documentation/locale-reference.html#topics
* @param optional int limit How many results do we want? Defaults to 3; set to "0" for all-we-can-get
* @param optional float similarity percentage of maximum allowed similarity of the titles. Defaults to 90; set to 0 to keep all
* @return array SearchResults (or empty array if none): int cachedate (UnixTime), array[0..n] of strings asin,title,url,img,price
* @brief note that product_type (SearchIndex) will NOT be verified, so make sure you chose valid ones :)
*/
public function getItemByKeyword($keyword, $product_type, $limit=3, $similarity=90) {
$keywords = urlencode($keyword);
$cachename = "${product_type}--${keywords}";
if ( empty($this->langPref) ) $cachename .= '_'.$this->localSite;
else $cachename .= '_'.substr($this->langPref,0,2);
$json = ''; $tmp = '';
$age = $this->getCache($cachename,$json);
if ( empty($json) && !$this->ApiLimitHit ) { // cache was either invalid or empty
$ItemRequest = $this->getRequestItemBase();
$ItemRequest->Keywords = $keyword;
$ItemRequest->SearchIndex = $product_type;
$ItemRequest->Resources = ["Images.Primary.Medium","ItemInfo.Title","Offers.Listings.Price"];
$json = $this->signedRequest($ItemRequest,'/paapi5/searchitems');
if ( !empty($json) ) {
$this->cache->setCache($json,$cachename);
}
$age = time();
}
if ( empty($json) ) return [];
$res = json_decode($json);
$cachedate = date('Y-m-d H:i',$age);
$resItems = count($res->SearchResult->Items);
$items = [];
for ($i=0; $i<$resItems; ++$i) {
$iasin = trim($res->SearchResult->Items[$i]->ASIN);
if (isset($res->SearchResult->Items[$i]->ItemInfo->Title->DisplayValue)) $title = trim($res->SearchResult->Items[$i]->ItemInfo->Title->DisplayValue); else continue;
if (isset($res->SearchResult->Items[$i]->DetailPageURL)) $url = trim($res->SearchResult->Items[$i]->DetailPageURL); else continue;
isset($res->SearchResult->Items[$i]->Images->Primary->Medium->URL) ? $img = trim($res->SearchResult->Items[$i]->Images->Primary->Medium->URL) : $img = '';
if ( $this->ssl ) $img = str_replace('http://ecx.images-amazon.com/','https://images-na.ssl-images-amazon.com/',$img);
if ( $this->imgBaseURL !== NULL ) { // share images from our server, so cache them
if ( $this->downloadImage($img,$iasin) ) $img = $this->imgBaseURL . "/${iasin}" . preg_replace('!.*(\.[^\.]+)$!','\1',$img);
}
isset($res->SearchResult->Items[$i]->Offers->Listings[0]->Price->DisplayAmount) ? $price = trim($res->SearchResult->Items[$i]->Offers->Listings[0]->Price->DisplayAmount) : $price = '';
$items[] = [
'asin' => $iasin,
'title' => preg_replace('!([A-z],)([A-z])!','\1&#8203;\2',$title), // insert zero-width space if comma not followed by space, for auto line breaks
'url' => preg_replace('/&(?!amp;)/','&amp;',$url),
'img' => $img,
'price' => $price
];
}
// remove items which are too similar
if ($similarity > 0) $this->removeSimilar($items, $similarity);
if ( $limit < 1 || count($items) <= $limit ) { // getAll
return array(
'cachedate' => $cachedate,
'items' => $items
);
}
$ids = array_rand($items,$limit);
$litems = array();
if ( is_array($ids) ) {
foreach ($ids as $id) $litems[] = $items[$id];
} else {
$litems[] = $items[$ids];
}
return array(
'cachedate' => $cachedate,
'items' => $litems
);
}
}
?>