*/ private array $priorityBrands; /** * @param AmazonDiscountCatalog $catalog Amazon catalog. * @param AmazonDiscountRepository $repository Snapshot repository. * @param (callable(int):void)|null $sleep Sleep seam for rate limiting. * @param list $priorityBrands Brands synchronized before categories. */ public function __construct( private AmazonDiscountCatalog $catalog, private AmazonDiscountRepository $repository, ?callable $sleep = null, array $priorityBrands = array() ) { $this->sleep = $sleep ?? static function ( int $seconds ): void { sleep( $seconds ); }; $this->priorityBrands = AmazonDiscountBrand::normalize( $priorityBrands ); } /** * Run a full physical-category sweep. */ public function sync(): AmazonDiscountSyncResult { $saved = array(); $errors = array(); $requestMade = false; foreach ( $this->priorityBrands as $brand ) { $key = AmazonDiscountBrand::storageKey( $brand ); try { $products = $this->fetchCollection( $key, 'All', $brand, $requestMade ); $saved[ $key ] = $this->repository->replaceBrand( $brand, $products ); } catch ( Throwable $error ) { $errors[ $key ] = $error->getMessage(); } } foreach ( AmazonDiscountCategory::all() as $searchIndex ) { try { $products = $this->fetchCollection( $searchIndex, $searchIndex, null, $requestMade ); $saved[ $searchIndex ] = $this->repository->replaceCategory( $searchIndex, $products ); } catch ( Throwable $error ) { $errors[ $searchIndex ] = $error->getMessage(); } } if ( $saved === array() ) { throw new RuntimeException( 'Amazon discount sync failed for every category.' ); } return new AmazonDiscountSyncResult( $saved, $errors ); } /** * Fetch all pages for one category or brand collection. * * @param string $collectionKey Stored collection key. * @param string $searchIndex Amazon SearchIndex. * @param string|null $brand Optional brand filter. * @param bool $requestMade Whether an earlier request was made. * * @return list */ private function fetchCollection( string $collectionKey, string $searchIndex, ?string $brand, bool &$requestMade ): array { $products = array(); $seen = array(); for ( $page = 1; $page <= self::DEFAULT_PAGES_PER_CATEGORY; ++$page ) { if ( $requestMade ) { $this->pause( self::REQUEST_INTERVAL_SECONDS ); } $requestMade = true; $result = $this->searchWithRetry( $searchIndex, $page, $brand ); $items = $result->items(); if ( $items === array() ) { break; } foreach ( $items as $offset => $snapshot ) { if ( $brand !== null && ! AmazonDiscountBrand::matches( $brand, $snapshot->brand() ) ) { continue; } $saving = self::savingsPercentage( $snapshot ); if ( $saving === null || $saving < self::DEFAULT_MIN_SAVING_PERCENT ) { continue; } $asin = $snapshot->providerProductId()->value(); if ( isset( $seen[ $asin ] ) ) { continue; } $seen[ $asin ] = true; $products[] = $this->toRecord( $collectionKey, ( $page - 1 ) * self::ITEMS_PER_PAGE + $offset + 1, $snapshot ); } if ( ! $result->hasMore() ) { break; } } return $products; } /** * Search one page with bounded rate-limit retries. * * @param string $searchIndex Amazon SearchIndex. * @param int $page One-based page. * @param string|null $brand Optional brand filter. */ private function searchWithRetry( string $searchIndex, int $page, ?string $brand ): \Affilun\Domain\Search\SearchResult { $backoffs = array( 2, 4, 8 ); foreach ( array( 0, 1, 2, 3 ) as $attempt ) { try { return $brand === null ? $this->catalog->searchDiscountProducts( $searchIndex, self::DEFAULT_MIN_SAVING_PERCENT, $page, self::ITEMS_PER_PAGE ) : $this->catalog->searchBrandDiscountProducts( $brand, self::DEFAULT_MIN_SAVING_PERCENT, $page, self::ITEMS_PER_PAGE ); } catch ( RateLimitExceededException $error ) { if ( ! isset( $backoffs[ $attempt ] ) ) { throw $error; } $this->pause( $backoffs[ $attempt ] ); } } throw new RuntimeException( 'Amazon discount search retry loop exhausted.' ); } /** * Convert a provider snapshot into a stored discount row. * * @param string $searchIndex Amazon SearchIndex. * @param int $position Upstream result position. * @param ProductSnapshot $snapshot Provider snapshot. */ private function toRecord( string $searchIndex, int $position, ProductSnapshot $snapshot ): AmazonDiscountProduct { $price = $snapshot->price(); $affiliate = $this->catalog->buildAffiliateLink( $snapshot, AffiliateContext::empty() ); return new AmazonDiscountProduct( $searchIndex, $position, $snapshot->providerProductId()->value(), $snapshot->title(), $snapshot->brand(), $snapshot->detailUrl(), $affiliate->url(), $snapshot->primaryImage(), $price?->amount(), $price?->currency(), $price?->label(), self::savingsPercentage( $snapshot ), self::DEFAULT_MIN_SAVING_PERCENT, $snapshot->fetchedAt() ); } /** * Extract the selected listing saving percentage. * * @param ProductSnapshot $snapshot Provider snapshot. */ private static function savingsPercentage( ProductSnapshot $snapshot ): ?int { $offers = $snapshot->rawMeta()['offersV2'] ?? null; if ( ! is_array( $offers ) ) { return null; } $listings = $offers['listings'] ?? null; if ( ! is_array( $listings ) ) { return null; } $listing = AmazonCreatorsApiListingSelector::select( $listings ); return $listing === null ? null : self::listingSavingsPercentage( $listing ); } /** * Extract a saving percentage from one listing. * * @param array $listing Creators API listing. */ private static function listingSavingsPercentage( array $listing ): ?int { $value = $listing['dealDetails']['savingsPercentage'] ?? $listing['price']['savings']['percentage'] ?? $listing['price']['savingsPercentage'] ?? $listing['savingsPercentage'] ?? null; if ( is_int( $value ) ) { return max( 0, min( 100, $value ) ); } if ( is_numeric( $value ) ) { return max( 0, min( 100, (int) $value ) ); } return null; } /** * Pause through the injected sleep seam. * * @param int $seconds Pause duration. */ private function pause( int $seconds ): void { $sleep = $this->sleep; $sleep( $seconds ); } }