*/ private $postRowsProvider; /** * Construct the use case. * * @param ProductRepository $repository Product repository. * @param Clock $clock Clock for import timestamps. * @param callable $postRowsProvider Supplies candidate post rows. * * @phpstan-param callable(): list $postRowsProvider */ public function __construct( ProductRepository $repository, Clock $clock, callable $postRowsProvider ) { $this->repository = $repository; $this->clock = $clock; $this->postRowsProvider = $postRowsProvider; } /** * Count detected products, including products already present in affilun. */ public function detectedCount(): int { return count( $this->detectCandidates() ); } /** * Count detected products that are not already present in affilun. */ public function pendingCount(): int { return count( $this->pendingCandidates() ); } /** * Import detected products that are not already present in affilun. */ public function import(): ExternalAffiliateProductImportResult { $candidates = $this->detectCandidates(); $save = new SaveProductUseCase( $this->repository, $this->clock ); $imported = 0; $updated = 0; foreach ( $candidates as $candidate ) { $result = $save->execute( $this->toSnapshot( $candidate ) ); if ( $result->created() ) { ++$imported; } else { ++$updated; } } return new ExternalAffiliateProductImportResult( count( $candidates ), $imported, $updated ); } /** * Return unique detected products that do not already exist in affilun. * * @return list */ public function pendingCandidates(): array { $pending = array(); foreach ( $this->detectCandidates() as $candidate ) { $existing = $this->repository->findByProviderItem( $candidate->provider(), $candidate->providerItemId() ); if ( $existing !== null ) { continue; } $pending[] = $candidate; } return $pending; } /** * Detect all unique importable candidates from supplied post content. * * @return list */ public function detectCandidates(): array { $rows = ( $this->postRowsProvider )(); $candidates = array(); foreach ( $rows as $row ) { $content = is_string( $row['content'] ?? null ) ? $row['content'] : ''; foreach ( self::extractCandidatesFromContent( $content ) as $candidate ) { $candidates[ $candidate->identityKey() ] = $candidate; } } return array_values( $candidates ); } /** * Extract importable product candidates from a post-content string. * * @param string $content Post content. * * @return list */ public static function extractCandidatesFromContent( string $content ): array { if ( $content === '' || ! str_contains( $content, '/s', $content, $matches ); if ( $matchCount === false || $matchCount < 1 ) { return array(); } $candidates = array(); foreach ( $matches[1] as $json ) { $attrs = self::decodeBlockAttributes( (string) $json ); if ( $attrs === null ) { continue; } $candidate = self::candidateFromAttributes( $attrs ); if ( $candidate !== null ) { $candidates[ $candidate->identityKey() ] = $candidate; } } return array_values( $candidates ); } /** * Decode Gutenberg block attributes JSON. * * @param string $json Raw block attributes JSON. * * @return array|null */ private static function decodeBlockAttributes( string $json ): ?array { $json = trim( $json ); if ( $json === '' ) { return null; } $decoded = json_decode( $json, true ); if ( is_array( $decoded ) ) { /** @var array $decoded */ return $decoded; } $cleanJson = self::stripInvalidUtf8( $json ); if ( $cleanJson === $json ) { return null; } $decoded = json_decode( $cleanJson, true ); if ( ! is_array( $decoded ) ) { return null; } /** @var array $decoded */ return $decoded; } /** * Strip invalid UTF-8 so legacy content can still be decoded when possible. * * @param string $value Raw string. */ private static function stripInvalidUtf8( string $value ): string { if ( function_exists( 'mb_convert_encoding' ) ) { return (string) mb_convert_encoding( $value, 'UTF-8', 'UTF-8' ); } if ( function_exists( 'iconv' ) ) { $converted = iconv( 'UTF-8', 'UTF-8//IGNORE', $value ); return is_string( $converted ) ? $converted : $value; } return $value; } /** * @param array $attrs Block attributes. */ private static function candidateFromAttributes( array $attrs ): ?ExternalAffiliateProductCandidate { $asin = self::attrString( $attrs, 'asin' ); $title = self::attrString( $attrs, 'title' ); if ( $asin === null || $title === null ) { return null; } $asin = strtoupper( preg_replace( '/[^A-Z0-9]/i', '', $asin ) ?? '' ); if ( $asin === '' ) { return null; } $affiliateUrl = self::validUrlOrNull( self::attrString( $attrs, 'amazon_affi_url' ) ); $detailUrl = self::amazonDetailUrl( $asin ); $imageUrl = self::validUrlOrNull( self::attrString( $attrs, 'image_url' ) ); return new ExternalAffiliateProductCandidate( 'amazon', $asin, $title, self::attrString( $attrs, 'info' ), $detailUrl, $affiliateUrl, $imageUrl, self::normalizePriceAmount( self::attrString( $attrs, 'price' ) ), $attrs ); } /** * Build a canonical Amazon JP detail URL from an ASIN. * * @param string $asin Amazon ASIN. */ private static function amazonDetailUrl( string $asin ): string { return 'https://www.amazon.co.jp/dp/' . rawurlencode( $asin ); } /** * Read a scalar attribute as a trimmed string. * * @param array $attrs Block attributes. * @param string $key Attribute key. */ private static function attrString( array $attrs, string $key ): ?string { if ( ! array_key_exists( $key, $attrs ) || ! is_scalar( $attrs[ $key ] ) ) { return null; } $value = trim( (string) $attrs[ $key ] ); return $value === '' ? null : $value; } /** * Return a URL only when it is valid. * * @param string|null $value Candidate URL. */ private static function validUrlOrNull( ?string $value ): ?string { if ( $value === null ) { return null; } return filter_var( $value, FILTER_VALIDATE_URL ) === false ? null : $value; } /** * Normalize a display price into a decimal amount string. * * @param string|null $value Candidate price. */ private static function normalizePriceAmount( ?string $value ): ?string { if ( $value === null ) { return null; } $amount = preg_replace( '/[^\d.]/', '', $value ); if ( $amount === null || $amount === '' || ! preg_match( '/^\d+(?:\.\d{1,2})?$/', $amount ) ) { return null; } return ltrim( $amount, '0' ) === '' ? '0' : ltrim( $amount, '0' ); } /** * Convert an import candidate to the canonical product snapshot. * * @param ExternalAffiliateProductCandidate $candidate Import candidate. */ private function toSnapshot( ExternalAffiliateProductCandidate $candidate ): ProductSnapshot { $now = $this->clock->now(); $price = $candidate->priceAmount() === null ? null : new PriceInfo( $candidate->priceAmount(), 'JPY', null, $now, true, false, null ); try { return new ProductSnapshot( $candidate->provider(), new ProviderProductId( $candidate->provider(), $candidate->providerItemId() ), $candidate->title(), $candidate->brand(), null, $candidate->detailUrl(), $candidate->affiliateUrl(), $candidate->imageUrl(), array(), $price, AvailabilityInfo::unknown(), null, null, array(), array(), $now, array( 'import_source' => 'external_affiliate_link_data', 'attributes' => $candidate->sourceAttributes(), ) ); } catch ( InvalidArgumentException $exception ) { throw new InvalidArgumentException( 'Detected affiliate-link product data could not be normalized for import.', 0, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing chained exception, never rendered to HTML. $exception ); } } }