*/ private const AI_SCAN_SEARCH_EXCLUDED_PROVIDERS = array( 'mercari' ); /** * @var callable(list): list> */ private $postLoader; /** * @var callable(int,string): bool */ private $postUpdater; /** * @var callable(PluginSettings,string,string): list */ private $keywordExtractor; /** * @var callable(PluginSettings,string,string,list,list>): array */ private $affiliateScanEvaluator; /** * @var list */ private array $lastRunErrors = array(); /** * @param SettingsRepository $settings Settings repository. * @param OptionStore $options Option store. * @param ProviderRegistry $providerRegistry Provider registry. * @param SaveProductUseCase $saveProduct Product save use case. * @param callable|null $postLoader Optional post loader. * @param callable|null $postUpdater Optional post updater. * @param callable|null $keywordExtractor Optional Cloud keyword extractor. * @param callable|null $affiliateScanEvaluator Optional Cloud affiliate scan evaluator. * * @phpstan-param (callable(list): list>)|null $postLoader * @phpstan-param (callable(int,string): bool)|null $postUpdater * @phpstan-param (callable(PluginSettings,string,string): list)|null $keywordExtractor * @phpstan-param (callable(PluginSettings,string,string,list,list>): array)|null $affiliateScanEvaluator */ public function __construct( private SettingsRepository $settings, private OptionStore $options, private ProviderRegistry $providerRegistry, private SaveProductUseCase $saveProduct, ?callable $postLoader = null, ?callable $postUpdater = null, ?callable $keywordExtractor = null, ?callable $affiliateScanEvaluator = null ) { $this->postLoader = $postLoader ?? self::defaultPostLoader(); $this->postUpdater = $postUpdater ?? self::defaultPostUpdater(); $this->keywordExtractor = $keywordExtractor ?? self::defaultKeywordExtractor(); $this->affiliateScanEvaluator = $affiliateScanEvaluator ?? self::defaultAffiliateScanEvaluator(); } /** * Execute AI scan for selected posts. * * @param list $postIds Selected post IDs. * * @return array{selected:int,scanned:int,updated:int,no_candidate:int,skipped:int,failed:int} */ public function run( array $postIds ): array { $this->lastRunErrors = array(); $ids = self::normalizeIds( $postIds ); if ( $ids === array() ) { return self::emptyResult(); } $settings = $this->settings->load(); $providers = $this->enabledSearchProviders( $settings ); if ( $providers === array() || ! $settings->cloud()->isConfigured() ) { $result = self::emptyResult(); $result['selected'] = count( $ids ); $result['failed'] = count( $ids ); foreach ( $ids as $postId ) { $this->lastRunErrors[] = self::jobError( $postId, 'Cloud settings or provider credentials are not configured.' ); } return $result; } $searchUseCase = new SearchProductsUseCase( $providers ); $posts = ( $this->postLoader )( $ids ); $noCandidate = $this->loadNoCandidateMap(); $result = self::emptyResult(); $result['selected'] = count( $ids ); foreach ( $posts as $post ) { $this->renewClaim(); $postId = self::positiveInt( $post['id'] ?? null ); $title = self::stringValue( $post['title'] ?? '' ); $content = self::stringValue( $post['content'] ?? '' ); if ( $postId === null || $content === '' || self::hasAffiliateContent( $content ) ) { ++$result['skipped']; continue; } try { $keywords = ( $this->keywordExtractor )( $settings, $title, $content ); $this->renewClaim(); $candidates = $this->searchProviderCandidates( $searchUseCase, array_keys( $providers ), $keywords ); $this->renewClaim(); $decision = ( $this->affiliateScanEvaluator )( $settings, $title, $content, $keywords, self::candidatePayloads( $candidates ) ); $this->renewClaim(); $selected = self::selectedCandidates( $decision, $candidates ); ++$result['scanned']; if ( $selected === array() ) { $noCandidate[ (string) $postId ] = gmdate( DATE_ATOM ); ++$result['no_candidate']; continue; } $snapshot = array_shift( $selected ); $urlDrafts = self::urlDraftsFor( $selected ); $saveResult = $this->saveProduct->execute( $snapshot, $urlDrafts ); $productId = $saveResult->record()->id(); if ( $productId === null ) { ++$result['failed']; $this->lastRunErrors[] = self::jobError( $postId, 'Product save did not return an id.' ); continue; } $updatedContent = self::appendProductCard( $content, $productId ); $this->renewClaim(); if ( ( $this->postUpdater )( $postId, $updatedContent ) ) { unset( $noCandidate[ (string) $postId ] ); ++$result['updated']; } else { ++$result['failed']; $this->lastRunErrors[] = self::jobError( $postId, 'WordPress post update did not return a saved post id.' ); } } catch ( Throwable $error ) { if ( $this->claimLost ) { throw $error; } ++$result['failed']; $this->lastRunErrors[] = self::jobError( $postId, self::boundedErrorMessage( $error ) ); } } $this->renewClaim(); $this->saveNoCandidateMap( $noCandidate, $ids ); return $result; } /** * Latest per-post errors captured by {@see run()}. * * @return list */ public function lastRunErrors(): array { return $this->lastRunErrors; } /** * Enqueue selected posts for background AI scanning. * * @param list $postIds Selected post IDs. * * @return array */ public function enqueue( array $postIds ): array { $ids = self::normalizeIds( $postIds, 500 ); $job = self::newJob( $ids ); if ( $ids === array() ) { $job['status'] = 'succeeded'; $job['finished_at'] = gmdate( DATE_ATOM ); } $this->mutateJobs( static function ( array $jobs ) use ( $job ): array { $jobs[ $job['id'] ] = $job; return $jobs; } ); return $job; } /** * Process one queued/running AI scan job batch. * * @param int $batchSize Maximum posts to process this tick. * * @return array|null */ public function tick( int $batchSize = self::DEFAULT_JOB_BATCH_SIZE ): ?array { $claim = null; $this->mutateJobs( function ( array $jobs ) use ( &$claim ): array { $claim = null; $jobId = $this->nextRunnableJobId( $jobs ); if ( $jobId === null ) { return $jobs; } $job = $jobs[ $jobId ]; $claim = array( 'id' => $jobId, 'token' => bin2hex( random_bytes( 16 ) ), 'generation' => self::intValue( $job['claim_generation'] ?? 0 ) + 1, ); $job['status'] = 'running'; $job['claim_token'] = $claim['token']; $job['claim_generation'] = $claim['generation']; $job['lease_expires_at'] = time() + self::CLAIM_SECONDS; $job['updated_at'] = gmdate( DATE_ATOM ); $jobs[ $jobId ] = $job; return $jobs; } ); if ( $claim === null ) { return null; } $job = $this->job( $claim['id'] ); if ( $job === null ) { throw new RuntimeException( 'Claimed Cloud scan job is missing.' ); } $job['last_error'] = null; $pending = self::jobPendingIds( $job ); $batchIds = array_slice( $pending, 0, max( 1, min( 20, $batchSize ) ) ); $job['pending_ids'] = array_values( array_slice( $pending, count( $batchIds ) ) ); $this->activeClaim = $claim; $this->claimLost = false; try { $result = $this->run( $batchIds ); } catch ( Throwable $error ) { if ( self::ownsClaim( $this->job( $claim['id'] ) ?? array(), $claim ) ) { throw $error; } return $this->job( $claim['id'] ); } finally { $this->activeClaim = null; } $job['result'] = self::mergeResults( self::jobResult( $job ), $result ); $job['errors'] = self::mergeJobErrors( self::jobErrors( $job ), $this->lastRunErrors() ); $job['failed_ids'] = self::mergeFailedIds( self::jobFailedIds( $job ), $this->lastRunErrors() ); $job['progress_done'] = self::intValue( $job['progress_done'] ?? 0 ) + count( $batchIds ); $job['updated_at'] = gmdate( DATE_ATOM ); if ( $job['pending_ids'] === array() ) { $job['status'] = self::intValue( $job['result']['failed'] ?? 0 ) > 0 ? 'failed' : 'succeeded'; $job['finished_at'] = gmdate( DATE_ATOM ); $job['last_error'] = $job['status'] === 'failed' ? self::latestJobErrorMessage( self::jobErrors( $job ) ) : null; } $job['claim_token'] = null; $job['lease_expires_at'] = null; $this->mutateJobs( static function ( array $jobs ) use ( $claim, $job ): array { if ( self::ownsClaim( $jobs[ $claim['id'] ] ?? array(), $claim ) ) { $jobs[ $claim['id'] ] = $job; } return $jobs; } ); return $this->job( $claim['id'] ); } /** * Whether any background AI scan job still needs processing. */ public function hasPendingJobs(): bool { foreach ( $this->loadJobs() as $job ) { if ( in_array( $job['status'] ?? '', array( 'queued', 'running' ), true ) ) { return true; } } return false; } /** * Fetch a stored background job. * * @param string $jobId Job identifier. * * @return array|null */ public function job( string $jobId ): ?array { $jobs = $this->loadJobs(); return isset( $jobs[ $jobId ] ) && is_array( $jobs[ $jobId ] ) ? $jobs[ $jobId ] : null; } /** * Latest stored background job, if any. * * @return array|null */ public function latestJob(): ?array { $jobs = $this->loadJobs(); if ( $jobs === array() ) { return null; } uasort( $jobs, static fn ( array $a, array $b ): int => strcmp( self::stringValue( $b['created_at'] ?? '' ), self::stringValue( $a['created_at'] ?? '' ) ) ); $job = reset( $jobs ); return is_array( $job ) ? $job : null; } /** * Detect whether content already contains an affiliate/product card. * * @param string $content Post content. */ public static function hasAffiliateContent( string $content ): bool { if ( str_contains( $content, 'wp:affilun/product-card' ) || preg_match( '/\[affilun_product_card\b/i', $content ) === 1 || preg_match( '/amazon_affi_url|["\']asin["\']|item\.rakuten\.co\.jp|shopping\.yahoo\.co\.jp/i', $content ) === 1 ) { return true; } return false; } /** * Append a Gutenberg product-card block to post content. * * @param string $content Post content. * @param int $productId Saved affilun product id. */ public static function appendProductCard( string $content, int $productId ): string { $card = ''; $trimmed = rtrim( $content ); if ( $trimmed === '' ) { return $card; } return $trimmed . "\n\n" . $card; } /** * @return array */ public function noCandidateMap(): array { return $this->loadNoCandidateMap(); } /** * @param SearchProductsUseCase $searchUseCase Product search use case. * @param list $providers Provider keys. * @param list $keywords Search keywords in priority order. * * @return array */ private function searchProviderCandidates( SearchProductsUseCase $searchUseCase, array $providers, array $keywords ): array { $candidates = array(); foreach ( $keywords as $keyword ) { $trimmed = trim( $keyword ); if ( $trimmed === '' ) { continue; } foreach ( $providers as $provider ) { if ( self::isExcludedAiScanSearchProvider( $provider ) ) { continue; } $query = new SearchQuery( $trimmed, 1, 5, null, null, null, null, array(), 'ja-JP', 'JPY', 'cloud_ai_post_scan' ); $this->renewClaim(); try { $bundle = $searchUseCase->execute( $query, $provider ); } catch ( Throwable $error ) { if ( $error instanceof ProviderException ) { continue; } throw $error; } finally { $this->renewClaim(); } foreach ( $bundle->results() as $result ) { foreach ( $result->items() as $snapshot ) { $key = self::candidateKey( $snapshot ); if ( ! array_key_exists( $key, $candidates ) ) { $candidates[ $key ] = $snapshot; } } } } } return array_slice( $candidates, 0, 60, true ); } /** * Whether an enabled provider should be skipped by automatic AI scans. * * @param string $provider Provider key. */ private static function isExcludedAiScanSearchProvider( string $provider ): bool { return in_array( strtolower( trim( $provider ) ), self::AI_SCAN_SEARCH_EXCLUDED_PROVIDERS, true ); } /** * @param array $candidates Candidate snapshots. * * @return list> */ private static function candidatePayloads( array $candidates ): array { $payloads = array(); foreach ( $candidates as $snapshot ) { $payloads[] = self::candidatePayload( $snapshot ); } return $payloads; } /** * @param ProductSnapshot $snapshot Candidate snapshot. * * @return array */ private static function candidatePayload( ProductSnapshot $snapshot ): array { $price = $snapshot->price(); $availability = $snapshot->availability(); $payload = array( 'provider' => self::boundedString( $snapshot->provider(), 80 ), 'provider_item_id' => self::boundedString( $snapshot->providerProductId()->value(), 255 ), 'title' => self::boundedString( $snapshot->title(), 500 ), 'brand' => self::boundedNullableString( $snapshot->brand(), 255 ), 'description' => self::boundedNullableString( $snapshot->description(), 1200 ), 'detail_url' => self::boundedNullableString( $snapshot->detailUrl(), 2048 ), 'affiliate_url' => self::boundedNullableString( $snapshot->affiliateUrl(), 2048 ), 'image_url' => self::boundedNullableString( $snapshot->primaryImage(), 2048 ), 'price_amount' => $price !== null ? self::boundedString( $price->amount(), 64 ) : null, 'price_currency' => $price !== null ? self::boundedString( $price->currency(), 16 ) : null, 'price_label' => $price !== null ? self::boundedNullableString( $price->label(), 120 ) : null, 'availability_status' => self::boundedNullableString( $availability->status(), 64 ), 'rating' => self::boundedNullableString( $snapshot->rating(), 32 ), 'review_count' => $snapshot->reviewCount(), 'categories' => self::boundedStringList( $snapshot->categories(), 20, 255 ), ); $attributes = self::boundedAttributeMap( $snapshot->attributes(), 40, 120, 512 ); if ( $attributes !== array() ) { $payload['attributes'] = $attributes; } return $payload; } /** * @param string $value Raw value. * @param int $length Maximum character length. */ private static function boundedString( string $value, int $length ): string { $value = trim( $value ); if ( function_exists( 'mb_substr' ) ) { return mb_substr( $value, 0, $length ); } return substr( $value, 0, $length ); } /** * @param string|null $value Raw value. * @param int $length Maximum character length. */ private static function boundedNullableString( ?string $value, int $length ): ?string { if ( $value === null ) { return null; } $value = self::boundedString( $value, $length ); return $value !== '' ? $value : null; } /** * @param list $values Raw values. * @param int $countLimit Maximum number of values. * @param int $itemLimit Maximum character length per value. * * @return list */ private static function boundedStringList( array $values, int $countLimit, int $itemLimit ): array { $result = array(); foreach ( array_slice( $values, 0, $countLimit ) as $value ) { $value = self::boundedString( $value, $itemLimit ); if ( $value !== '' ) { $result[] = $value; } } return $result; } /** * @param array $attributes Raw attributes. * @param int $countLimit Maximum number of attributes. * @param int $keyLimit Maximum character length per key. * @param int $valueLimit Maximum character length per string value. * * @return array */ private static function boundedAttributeMap( array $attributes, int $countLimit, int $keyLimit, int $valueLimit ): array { $result = array(); foreach ( $attributes as $key => $value ) { if ( count( $result ) >= $countLimit ) { break; } $key = self::boundedString( $key, $keyLimit ); if ( $key === '' ) { continue; } if ( is_string( $value ) ) { $result[ $key ] = self::boundedString( $value, $valueLimit ); } else { $result[ $key ] = $value; } } return $result; } /** * @param array $decision Cloud decision payload. * @param array $candidates Candidate snapshots by key. * * @return list */ private static function selectedCandidates( array $decision, array $candidates ): array { $group = isset( $decision['selected_group'] ) && is_array( $decision['selected_group'] ) ? $decision['selected_group'] : null; if ( $group === null || self::floatValue( $group['confidence'] ?? null ) < 0.7 ) { return array(); } $selected = array(); $items = isset( $group['items'] ) && is_array( $group['items'] ) ? $group['items'] : array(); foreach ( $items as $item ) { if ( ! is_array( $item ) ) { continue; } if ( self::floatValue( $item['confidence'] ?? null ) < 0.7 || self::floatValue( $item['relevance_score'] ?? null ) < 0.6 || self::floatValue( $item['same_product_score'] ?? null, 1.0 ) < 0.65 ) { continue; } $key = self::candidateKeyFromParts( self::stringValue( $item['provider'] ?? '' ), self::stringValue( $item['provider_item_id'] ?? '' ) ); if ( $key !== '' && array_key_exists( $key, $candidates ) ) { $selected[] = $candidates[ $key ]; } } return $selected; } /** * @param list $snapshots Selected secondary snapshots. * * @return list */ private static function urlDraftsFor( array $snapshots ): array { $drafts = array(); foreach ( $snapshots as $snapshot ) { $drafts[] = new ProductUrlDraft( $snapshot->provider(), $snapshot->providerProductId()->value(), $snapshot->detailUrl(), 'provider' ); if ( $snapshot->affiliateUrl() !== null ) { $drafts[] = new ProductUrlDraft( $snapshot->provider(), $snapshot->providerProductId()->value(), $snapshot->affiliateUrl(), 'provider' ); } } return $drafts; } /** * Build a stable candidate key from a snapshot. * * @param ProductSnapshot $snapshot Candidate snapshot. */ private static function candidateKey( ProductSnapshot $snapshot ): string { return self::candidateKeyFromParts( $snapshot->provider(), $snapshot->providerProductId()->value() ); } /** * Build a stable candidate key from provider fields. * * @param string $provider Provider key. * @param string $providerItemId Provider item id. */ private static function candidateKeyFromParts( string $provider, string $providerItemId ): string { $provider = trim( $provider ); $providerItemId = trim( $providerItemId ); if ( $provider === '' || $providerItemId === '' ) { return ''; } return strtolower( $provider ) . "\0" . $providerItemId; } /** * Normalize a Cloud score into the 0.0-1.0 range. * * @param mixed $value Raw score. * @param float $defaultScore Default score. */ private static function floatValue( mixed $value, float $defaultScore = 0.0 ): float { if ( is_int( $value ) || is_float( $value ) || ( is_string( $value ) && is_numeric( $value ) ) ) { return max( 0.0, min( 1.0, (float) $value ) ); } return $defaultScore; } /** * @param PluginSettings $settings Plugin settings. * * @return array */ private function enabledSearchProviders( PluginSettings $settings ): array { $providers = array(); foreach ( $this->providerRegistry->all() as $key => $provider ) { $credentials = $settings->provider( $key ); if ( $provider instanceof SearchProviderInterface && $credentials !== null && $credentials->enabled() && $credentials->isConfigured() ) { $providers[ $key ] = $provider; } } return $providers; } /** * @return array */ private function loadNoCandidateMap(): array { $value = $this->options->get( self::NO_CANDIDATE_OPTION, array() ); if ( ! is_array( $value ) ) { return array(); } $map = array(); foreach ( $value as $key => $timestamp ) { $postId = self::positiveInt( $key ); if ( $postId === null || ! is_scalar( $timestamp ) ) { continue; } $map[ (string) $postId ] = trim( (string) $timestamp ); } return $map; } /** * @param array $map No-candidate map. * @param list $ids Processed post IDs. */ private function saveNoCandidateMap( array $map, array $ids ): void { for ( $attempt = 0; $attempt < 20; ++$attempt ) { $expected = $this->options->readAtomic( self::NO_CANDIDATE_OPTION ); $updated = $expected ?? array(); foreach ( $ids as $id ) { unset( $updated[ (string) $id ] ); if ( isset( $map[ (string) $id ] ) ) { $updated[ (string) $id ] = $map[ (string) $id ]; } } if ( $updated === $expected || $this->options->compareAndSwap( self::NO_CANDIDATE_OPTION, $expected, $updated ) ) { return; } } throw new RuntimeException( 'Cloud scan post state is busy; retry this batch.' ); } /** * @param list $postIds Post IDs. * @param int $limit Maximum number of IDs to keep. * * @return list */ private static function normalizeIds( array $postIds, int $limit = 20 ): array { $ids = array(); foreach ( $postIds as $postId ) { $id = self::positiveInt( $postId ); if ( $id !== null && ! in_array( $id, $ids, true ) ) { $ids[] = $id; } } return array_slice( $ids, 0, max( 1, $limit ) ); } /** * @return array{selected:int,scanned:int,updated:int,no_candidate:int,skipped:int,failed:int} */ private static function emptyResult(): array { return array( 'selected' => 0, 'scanned' => 0, 'updated' => 0, 'no_candidate' => 0, 'skipped' => 0, 'failed' => 0, ); } /** * @param list $ids Post IDs. * * @return array */ private static function newJob( array $ids ): array { $now = gmdate( DATE_ATOM ); $result = self::emptyResult(); $result['selected'] = count( $ids ); return array( 'id' => bin2hex( random_bytes( 12 ) ), 'status' => 'queued', 'post_ids' => $ids, 'pending_ids' => $ids, 'progress_done' => 0, 'progress_total' => count( $ids ), 'result' => $result, 'created_at' => $now, 'updated_at' => $now, 'finished_at' => null, 'last_error' => null, 'errors' => array(), 'failed_ids' => array(), ); } /** * @return array> */ private function loadJobs(): array { return self::jobRows( $this->options->readAtomic( self::JOBS_OPTION ) ?? array() ); } /** * @param array $value Stored rows. * @return array> */ private static function jobRows( array $value ): array { $jobs = array(); foreach ( $value as $jobId => $job ) { if ( is_string( $jobId ) && is_array( $job ) ) { $jobs[ $jobId ] = $job; } } return $jobs; } /** * @param callable(array>):array> $mutate Mutation. */ private function mutateJobs( callable $mutate ): void { for ( $attempt = 0; $attempt < 20; ++$attempt ) { $expected = $this->options->readAtomic( self::JOBS_OPTION ); $jobs = $mutate( self::jobRows( $expected ?? array() ) ); uasort( $jobs, static fn ( array $a, array $b ): int => strcmp( self::stringValue( $b['created_at'] ?? '' ), self::stringValue( $a['created_at'] ?? '' ) ) ); $finished = 0; foreach ( $jobs as $id => $job ) { if ( ! in_array( $job['status'] ?? '', array( 'queued', 'running' ), true ) && ++$finished > 20 ) { unset( $jobs[ $id ] ); } } if ( $jobs === $expected || $this->options->compareAndSwap( self::JOBS_OPTION, $expected, $jobs ) ) { return; } } throw new RuntimeException( 'Cloud scan queue is busy; retry the operation.' ); } /** * @param array $job Job row. * @param array{id:string,token:string,generation:int} $claim Worker claim. */ private static function ownsClaim( array $job, array $claim ): bool { return ( $job['status'] ?? '' ) === 'running' && ( $job['claim_token'] ?? null ) === $claim['token'] && ( $job['claim_generation'] ?? null ) === $claim['generation'] && self::intValue( $job['lease_expires_at'] ?? 0 ) > time(); } /** Renew only a still-live claim before performing another side effect. */ private function renewClaim(): void { $claim = $this->activeClaim; if ( $claim === null ) { return; } $owned = false; $this->mutateJobs( static function ( array $jobs ) use ( $claim, &$owned ): array { $owned = self::ownsClaim( $jobs[ $claim['id'] ] ?? array(), $claim ); if ( $owned ) { $jobs[ $claim['id'] ]['lease_expires_at'] = time() + self::CLAIM_SECONDS; } return $jobs; } ); if ( ! $owned ) { $this->claimLost = true; throw new RuntimeException( 'Cloud scan worker no longer owns this batch.' ); } } /** * @param array> $jobs Jobs by id. */ private function nextRunnableJobId( array $jobs ): ?string { foreach ( $jobs as $jobId => $job ) { $status = self::stringValue( $job['status'] ?? '' ); if ( $status === 'queued' || ( $status === 'running' && self::intValue( $job['lease_expires_at'] ?? 0 ) <= time() ) ) { return $jobId; } } return null; } /** * @param array $job Stored job. * * @return list */ private static function jobPendingIds( array $job ): array { $pending = isset( $job['pending_ids'] ) && is_array( $job['pending_ids'] ) ? $job['pending_ids'] : array(); return self::normalizeIds( $pending, 500 ); } /** * @param array $job Stored job. * * @return array{selected:int,scanned:int,updated:int,no_candidate:int,skipped:int,failed:int} */ private static function jobResult( array $job ): array { $result = isset( $job['result'] ) && is_array( $job['result'] ) ? $job['result'] : array(); return array( 'selected' => self::intValue( $result['selected'] ?? 0 ), 'scanned' => self::intValue( $result['scanned'] ?? 0 ), 'updated' => self::intValue( $result['updated'] ?? 0 ), 'no_candidate' => self::intValue( $result['no_candidate'] ?? 0 ), 'skipped' => self::intValue( $result['skipped'] ?? 0 ), 'failed' => self::intValue( $result['failed'] ?? 0 ), ); } /** * @param array $job Stored job. * * @return list */ private static function jobErrors( array $job ): array { $value = isset( $job['errors'] ) && is_array( $job['errors'] ) ? $job['errors'] : array(); $rows = array(); foreach ( $value as $row ) { if ( ! is_array( $row ) ) { continue; } $message = self::stringValue( $row['message'] ?? '' ); if ( $message === '' ) { continue; } $postId = self::positiveInt( $row['post_id'] ?? null ); $rows[] = array( 'post_id' => $postId, 'message' => self::boundedString( $message, 300 ), 'occurred_at' => self::stringValue( $row['occurred_at'] ?? '' ) ?: gmdate( DATE_ATOM ), ); } return array_slice( $rows, -20 ); } /** * @param array $job Stored job. * * @return list */ private static function jobFailedIds( array $job ): array { $value = isset( $job['failed_ids'] ) && is_array( $job['failed_ids'] ) ? $job['failed_ids'] : array(); return self::normalizeIds( $value, 500 ); } /** * @param array{selected:int,scanned:int,updated:int,no_candidate:int,skipped:int,failed:int} $current Current total. * @param array{selected:int,scanned:int,updated:int,no_candidate:int,skipped:int,failed:int} $next Batch result. * * @return array{selected:int,scanned:int,updated:int,no_candidate:int,skipped:int,failed:int} */ private static function mergeResults( array $current, array $next ): array { return array( 'selected' => $current['selected'], 'scanned' => $current['scanned'] + $next['scanned'], 'updated' => $current['updated'] + $next['updated'], 'no_candidate' => $current['no_candidate'] + $next['no_candidate'], 'skipped' => $current['skipped'] + $next['skipped'], 'failed' => $current['failed'] + $next['failed'], ); } /** * @param int|null $postId Post id when available. * @param string $message Failure message. * * @return array{post_id:int|null,message:string,occurred_at:string} */ private static function jobError( ?int $postId, string $message ): array { return array( 'post_id' => $postId, 'message' => self::boundedString( $message, 300 ), 'occurred_at' => gmdate( DATE_ATOM ), ); } /** * @param list $current Current errors. * @param list $next New errors. * * @return list */ private static function mergeJobErrors( array $current, array $next ): array { if ( $next === array() ) { return $current; } return array_slice( array_merge( $current, $next ), -20 ); } /** * @param list $current Current failed post ids. * @param list $errors New errors. * * @return list */ private static function mergeFailedIds( array $current, array $errors ): array { foreach ( $errors as $error ) { $postId = $error['post_id']; if ( $postId !== null && ! in_array( $postId, $current, true ) ) { $current[] = $postId; } } return $current; } /** * @param list $errors Stored errors. */ private static function latestJobErrorMessage( array $errors ): ?string { $error = end( $errors ); if ( ! is_array( $error ) ) { return null; } $postId = $error['post_id']; $message = $error['message']; return $postId !== null ? sprintf( 'Post %d: %s', $postId, $message ) : $message; } /** * @param Throwable $error Failure. */ private static function boundedErrorMessage( Throwable $error ): string { $message = trim( $error->getMessage() ); if ( $message === '' ) { $message = 'Unexpected ' . $error::class; } else { $message = $error::class . ': ' . $message; } return self::boundedString( $message, 300 ); } /** * @param mixed $value Candidate value. */ private static function intValue( mixed $value ): int { return is_int( $value ) ? max( 0, $value ) : ( is_numeric( $value ) ? max( 0, (int) $value ) : 0 ); } /** * @param mixed $value Candidate value. */ private static function positiveInt( mixed $value ): ?int { if ( is_int( $value ) ) { return $value > 0 ? $value : null; } if ( is_string( $value ) && ctype_digit( $value ) ) { $int = (int) $value; return $int > 0 ? $int : null; } return null; } /** * @param mixed $value Candidate value. */ private static function stringValue( mixed $value ): string { return is_scalar( $value ) ? trim( (string) $value ) : ''; } /** * @return callable(list): list> */ private static function defaultPostLoader(): callable { return static function ( array $postIds ): array { if ( ! function_exists( 'get_posts' ) ) { return array(); } $posts = get_posts( array( 'post__in' => $postIds, 'post_type' => 'post', 'post_status' => array( 'publish', 'draft', 'pending', 'future', 'private' ), 'posts_per_page' => count( $postIds ), 'orderby' => 'post__in', ) ); $rows = array(); foreach ( $posts as $post ) { $rows[] = array( 'id' => (int) $post->ID, 'title' => (string) $post->post_title, 'content' => (string) $post->post_content, ); } return $rows; }; } /** * @return callable(int,string): bool */ private static function defaultPostUpdater(): callable { return static function ( int $postId, string $content ): bool { if ( ! function_exists( 'wp_update_post' ) ) { return false; } $result = wp_update_post( array( 'ID' => $postId, 'post_content' => $content, ), true ); return is_int( $result ) && $result > 0; }; } /** * @return callable(PluginSettings,string,string): list */ private static function defaultKeywordExtractor(): callable { return static function ( PluginSettings $settings, string $title, string $content ): array { $provider = new CloudArticleSuggestionProvider( $settings->cloud() ); $result = $provider->suggest( $title, $content ); $keywords = array(); if ( $result->searchKeywords() !== '' ) { $keywords[] = $result->searchKeywords(); } foreach ( $result->candidates() as $candidate ) { if ( ! in_array( $candidate, $keywords, true ) ) { $keywords[] = $candidate; } } return array_slice( $keywords, 0, 5 ); }; } /** * @return callable(PluginSettings,string,string,list,list>): array */ private static function defaultAffiliateScanEvaluator(): callable { return static function ( PluginSettings $settings, string $title, string $content, array $keywords, array $candidates ): array { $client = new CloudAffiliateScanClient(); $payload = array( 'title' => self::boundedString( $title, 300 ), 'content' => self::boundedString( $content, 8000 ), 'locale' => 'ja-JP', 'keywords' => self::boundedStringList( $keywords, 10, 512 ), 'candidates' => array_slice( $candidates, 0, 60 ), ); return $client->evaluate( $settings->cloud(), $payload, time(), bin2hex( random_bytes( 12 ) ) ); }; } }