*/ private const PER_PAGE_OPTIONS = array( 10, 20, 50 ); /** * Provider returning post rows that may contain affilun blocks. * * @var callable(): list> */ private $postRowsProvider; /** * Provider returning post rows for the Cloud AI scan list. * * @var callable(): list> */ private $aiScanPostRowsProvider; /** * Campaign providers exposed in the user-facing selector. * * @var array */ private const CAMPAIGN_PROVIDERS = array( 'amazon' => 'Amazon', 'rakuten' => '楽天市場', 'yahoo_shopping' => 'Yahoo!ショッピング', 'mercari' => 'メルカリ', ); /** * Provider priority used when one normalized candidate has multiple offers. * * @var array */ private const TOP_AFFILIATE_PROVIDER_PRIORITY = array( 'amazon' => 0, 'rakuten' => 1, 'yahoo_shopping' => 2, ); /** * @param SettingsRepository $settings Settings repository. * @param CloudLinkHealthClient $linkHealth Link health client. * @param CloudCampaignFeedClient $campaigns Campaign client. * @param ProductRepository|null $productRepository Product repository. * @param ProductMetaRepository|null $productMetaRepository Product meta repository. * @param callable|null $postRowsProvider Optional post rows provider. * @param CloudTopAffiliateCandidatesClient $topAffiliateCandidates Top affiliate candidate client. * @param TopAffiliateCandidateRepository|null $topAffiliateCandidateRepository Candidate repository. * @param OptionStore|null $optionStore Option store. * @param callable|null $aiScanPostRowsProvider AI scan post provider. */ public function __construct( private SettingsRepository $settings, private CloudLinkHealthClient $linkHealth = new CloudLinkHealthClient(), private CloudCampaignFeedClient $campaigns = new CloudCampaignFeedClient(), private ?ProductRepository $productRepository = null, private ?ProductMetaRepository $productMetaRepository = null, ?callable $postRowsProvider = null, private CloudTopAffiliateCandidatesClient $topAffiliateCandidates = new CloudTopAffiliateCandidatesClient(), private ?TopAffiliateCandidateRepository $topAffiliateCandidateRepository = null, private ?OptionStore $optionStore = null, ?callable $aiScanPostRowsProvider = null ) { $this->postRowsProvider = $postRowsProvider ?? self::defaultPostRowsProvider(); $this->aiScanPostRowsProvider = $aiScanPostRowsProvider ?? self::defaultAiScanPostRowsProvider(); } /** * Render page. * * @param array $query Query parameters. */ public function render( array $query = array() ): void { $settings = $this->settings->load(); $cloud = $settings->cloud(); echo '
'; $this->renderHero(); if ( ! $cloud->isConfigured() ) { echo '

'; echo esc_html( 'affilun Cloud が未設定です。設定画面で Site ID と API Key を保存してください。' ); echo '

'; } echo '
'; $this->renderTopAffiliateCandidatesSection( $cloud, $settings, $query ); echo '
'; echo '
'; $this->renderCampaignSection( $cloud, $settings, $query ); echo '
'; echo '
'; $this->renderAiScanSection( $cloud, $query ); echo '
'; echo '
'; } /** * Render branded Cloud tools hero. */ private function renderHero(): void { $logoUrl = defined( 'AFFILUN_PLUGIN_URL' ) ? AFFILUN_PLUGIN_URL . 'assets/img/brand/logo-landscape-transparent.png' : ''; echo '
'; echo '
'; if ( $logoUrl !== '' ) { echo ''; } echo '

' . esc_html( 'つなぐ・分析する・成長する' ) . '

'; echo '

' . esc_html( 'Cloudツール' ) . '

'; echo '
'; echo ''; echo '
'; } /** * Render standalone link-health panel for the products page. * * @param array $query Query parameters. * @param string $pageSlug Admin page slug for the GET form. */ public function renderLinkHealthPanel( array $query = array(), string $pageSlug = 'affilun-products' ): void { $cloud = $this->settings->load()->cloud(); echo ''; } /** * Render link health section. * * @param CloudSettings $cloud Cloud settings. * @param array $query Query parameters. * @param string $pageSlug Admin page slug for the GET form. */ private function renderLinkHealthSection( CloudSettings $cloud, array $query, string $pageSlug ): void { echo '

' . esc_html( 'アフィリエイトリンク監視' ) . '

'; echo '

' . esc_html( '保存済み商品のアフィリエイトURLを affilun Cloud でチェックします。公開済みリンクの機会損失を見つけるための手動スキャンです。' ) . '

'; if ( ! $cloud->isConfigured() ) { echo '

'; echo esc_html( 'affilun Cloud が未設定です。設定画面で Site ID と API Key を保存してください。' ); echo '

'; return; } if ( ! self::cloudPlanAllowsLinkHealth( $cloud ) ) { echo '

'; echo esc_html( 'リンクヘルスチェックは有料またはトライアル中の affilun Cloud プランで利用できます。' ); echo '

'; return; } echo '
'; echo ''; self::renderNonceField( self::linkHealthSavedNonceAction( $pageSlug ), self::LINK_HEALTH_SAVED_NONCE_FIELD ); echo '
'; $jobId = self::queryString( $query, 'affilun_link_health_job' ); if ( $jobId !== '' ) { $this->renderLinkHealthJobPanel( $cloud, $jobId, $pageSlug, self::verifyQueryNonce( $query, self::linkHealthJobNonceAction( $jobId ), self::LINK_HEALTH_JOB_NONCE_FIELD ) ); return; } $urls = array(); $contexts = array(); $saved = self::queryString( $query, 'affilun_link_health_saved' ) === '1'; if ( $saved ) { if ( ! self::verifyQueryNonce( $query, self::linkHealthSavedNonceAction( $pageSlug ), self::LINK_HEALTH_SAVED_NONCE_FIELD ) ) { echo '

'; echo esc_html( 'リンクチェックのリクエストを確認できませんでした。もう一度実行してください。' ); echo '

'; return; } $contexts = self::mergeLinkHealthContexts( $this->savedProductUrlContexts(), $this->renderedBlockUrlContexts() ); $urls = array_keys( $contexts ); } if ( $urls === array() ) { if ( $saved ) { echo '

' . esc_html( 'チェック対象の保存済みリンクはありません。' ) . '

'; } return; } try { if ( count( $urls ) > self::DIRECT_LINK_HEALTH_LIMIT ) { $job = $this->linkHealth->createJob( $cloud, $urls, time(), self::nonce(), $contexts ); $this->renderLinkHealthJobCreated( $job, count( $urls ), $pageSlug ); } else { $results = $this->linkHealth->check( $cloud, $urls, time(), self::nonce(), $contexts ); $this->persistLinkHealthResults( $results, $contexts ); $this->renderLinkHealthScanSummary( count( $results ), $saved ); $this->renderLinkHealthResults( $results, $contexts ); } } catch ( Throwable $error ) { echo '

'; echo esc_html( self::linkHealthFailureMessage( $error ) ); echo '

'; } } /** * Whether the cached Cloud plan should expose manual link health checks. * * Empty/unknown plan values are allowed for backward compatibility with * older connected sites; Cloud remains the source of truth at request time. * * @param CloudSettings $cloud Cloud settings. */ private static function cloudPlanAllowsLinkHealth( CloudSettings $cloud ): bool { $plan = strtolower( trim( $cloud->plan() ) ); if ( $plan === '' ) { return true; } return in_array( $plan, array( 'personal', 'growth', 'agency' ), true ); } /** * Build a user-facing link-health failure message. * * @param Throwable $error Error raised by the Cloud client. */ private static function linkHealthFailureMessage( Throwable $error ): string { if ( $error instanceof CloudApiException ) { if ( $error->cloudCode() === 'affilun_cloud_plan_required' ) { return 'リンクヘルスチェックは現在の affilun Cloud プランでは利用できません。プランを確認してください。'; } if ( $error->cloudCode() === 'affilun_cloud_cost_quota_exhausted' || $error->cloudCode() === 'affilun_cloud_quota_exhausted' ) { return 'affilun Cloud の利用枠に達したため、リンクチェックを実行できませんでした。'; } if ( $error->cloudCode() === 'affilun_cloud_invalid_request' ) { return 'チェック対象リンクの形式に問題があるため、affilun Cloud のリンクチェックに失敗しました。'; } if ( $error->cloudCode() === 'affilun_cloud_link_health_jobs_unavailable' ) { return 'affilun Cloud のリンク監視ジョブを開始できませんでした。時間をおいて再実行してください。'; } if ( $error->statusCode() > 0 ) { return sprintf( 'affilun Cloud のリンクチェックに失敗しました(HTTP %d)。', $error->statusCode() ); } } return 'affilun Cloud のリンクチェックに失敗しました。'; } /** * Render a newly-created asynchronous link-health job. * * @param array $job Job payload. * @param int $submittedCount Number of submitted links. * @param string $pageSlug Admin page slug. */ private function renderLinkHealthJobCreated( array $job, int $submittedCount, string $pageSlug ): void { $jobId = is_scalar( $job['id'] ?? null ) ? (string) $job['id'] : ''; if ( $jobId === '' ) { echo '

'; echo esc_html( 'affilun Cloud のリンク監視ジョブを開始できませんでした。' ); echo '

'; return; } echo '

'; echo esc_html( sprintf( '保存済みリンク %d 件をチェック対象として送信しました。結果はジョブの完了後に確認できます。', $submittedCount ) ); echo '

'; $this->renderLinkHealthJobSummary( $job ); $this->renderLinkHealthJobRefreshForm( $jobId, $pageSlug ); } /** * Render an asynchronous link-health job status/results panel. * * @param CloudSettings $cloud Cloud settings. * @param string $jobId Job id. * @param string $pageSlug Admin page slug. * @param bool $persist Whether fetched results may be persisted. */ private function renderLinkHealthJobPanel( CloudSettings $cloud, string $jobId, string $pageSlug, bool $persist ): void { try { $response = $this->linkHealth->fetchJobResults( $cloud, $jobId, time(), self::nonce() ); $job = $response['job']; $status = is_scalar( $job['status'] ?? null ) ? (string) $job['status'] : ''; $this->renderLinkHealthJobSummary( $job ); if ( in_array( $status, array( 'queued', 'running' ), true ) ) { echo '

' . esc_html( 'リンク監視ジョブはまだ処理中です。しばらくしてから結果を更新してください。' ) . '

'; $this->renderLinkHealthJobRefreshForm( $jobId, $pageSlug ); return; } $results = $response['results']; $this->renderLinkHealthJobResultSummary( $response['summary'] ); if ( $persist ) { $this->persistLinkHealthResults( $results, self::contextsFromResults( $results ) ); } $this->renderLinkHealthResults( $results ); } catch ( Throwable $error ) { echo '

'; echo esc_html( self::linkHealthFailureMessage( $error ) ); echo '

'; } } /** * Render a compact link-health job summary. * * @param array $job Job payload. */ private function renderLinkHealthJobSummary( array $job ): void { $status = is_scalar( $job['status'] ?? null ) ? (string) $job['status'] : 'unknown'; $done = is_numeric( $job['progress_done'] ?? null ) ? (int) $job['progress_done'] : 0; $total = is_numeric( $job['progress_total'] ?? null ) ? (int) $job['progress_total'] : 0; echo '

'; echo esc_html( sprintf( 'ジョブ状態: %s / 進捗: %d / %d', $status, $done, $total ) ); echo '

'; if ( is_array( $job['error'] ?? null ) ) { $message = is_scalar( $job['error']['message'] ?? null ) ? (string) $job['error']['message'] : ''; if ( $message !== '' ) { echo '

'; echo esc_html( 'Cloud側の処理でエラーが発生しました: ' . $message ); echo '

'; } } } /** * Render job refresh form. * * @param string $jobId Job id. * @param string $pageSlug Admin page slug. */ private function renderLinkHealthJobRefreshForm( string $jobId, string $pageSlug ): void { echo '
'; echo ''; self::renderNonceField( self::linkHealthJobNonceAction( $jobId ), self::LINK_HEALTH_JOB_NONCE_FIELD ); echo '
'; } /** * Render summary returned by job results endpoint. * * @param array $summary Summary payload. */ private function renderLinkHealthJobResultSummary( array $summary ): void { $total = is_numeric( $summary['total_count'] ?? null ) ? (int) $summary['total_count'] : 0; $unchecked = is_numeric( $summary['unchecked_count'] ?? null ) ? (int) $summary['unchecked_count'] : 0; if ( $unchecked > 0 ) { echo '

'; echo esc_html( sprintf( '%d / %d 件はまだ未チェックです。', $unchecked, $total ) ); echo '

'; } } /** * Render link health scan summary. * * @param int $count Number of URLs submitted. * @param bool $saved Whether this is a saved-product scan. */ private function renderLinkHealthScanSummary( int $count, bool $saved ): void { $label = $saved ? '保存済みリンク' : '任意URL'; echo '

' . esc_html( sprintf( '%s %d 件をチェックしました。', $label, $count ) ) . '

'; } /** * Render link health result table. * * @param list $results Results. * @param array> $contexts Saved product context by URL. */ private function renderLinkHealthResults( array $results, array $contexts = array() ): void { if ( $results === array() ) { echo '

' . esc_html( 'チェック対象URLはありません。' ) . '

'; return; } $this->renderLinkHealthResultSummary( $results ); echo ''; foreach ( array( 'Product', 'URL', 'Status', 'HTTP', 'Message', 'Checked' ) as $heading ) { echo ''; } echo ''; foreach ( $results as $result ) { $context = self::contextForResult( $result, $contexts ); $label = is_scalar( $context['label'] ?? null ) ? (string) $context['label'] : '任意URL'; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html( $heading ) . '
' . esc_html( $label ) . '' . esc_html( $result->url() ) . '' . esc_html( $result->status() ) . '' . esc_html( (string) $result->httpStatus() ) . '' . esc_html( $result->message() ) . '' . esc_html( $result->checkedAtIso() ) . '
'; } /** * Render a compact result summary before the result table. * * @param list $results Results. */ private function renderLinkHealthResultSummary( array $results ): void { $counts = array( 'ok' => 0, 'warning' => 0, 'broken' => 0, 'unknown' => 0, ); foreach ( $results as $result ) { ++$counts[ self::linkHealthStatusClass( $result ) ]; } echo '

'; echo '' . esc_html( sprintf( '正常 %d', $counts['ok'] ) ) . ' '; echo '' . esc_html( sprintf( '注意 %d', $counts['warning'] ) ) . ' '; echo '' . esc_html( sprintf( '問題 %d', $counts['broken'] ) ) . ' '; if ( $counts['unknown'] > 0 ) { echo '' . esc_html( sprintf( '不明 %d', $counts['unknown'] ) ) . ''; } echo '

'; if ( $counts['broken'] > 0 ) { echo '

' . esc_html( sprintf( '問題のあるリンクが %d 件あります。商品一覧のリンク監視カラムで対象を確認してください。', $counts['broken'] ) ) . '

'; } elseif ( $counts['warning'] > 0 ) { echo '

' . esc_html( sprintf( '確認が必要なリンクが %d 件あります。リダイレクトやタイムアウトを確認してください。', $counts['warning'] ) ) . '

'; } } /** * Resolve CSS class suffix for link-health status. * * @param CloudLinkHealthResult $result Result. */ private static function linkHealthStatusClass( CloudLinkHealthResult $result ): string { return LinkHealthStatusClassifier::classify( $result->status(), $result->httpStatus() ); } /** * Persist latest link-health results to product meta. * * @param list $results Results. * @param array> $contexts Saved product context by URL. */ private function persistLinkHealthResults( array $results, array $contexts ): void { if ( $this->productMetaRepository === null || $contexts === array() ) { return; } $store = new ProductLinkHealthStatusStore( $this->productMetaRepository ); $store->persistResults( $results, $contexts, new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) ); } /** * Resolve display/persistence context for one result. * * @param CloudLinkHealthResult $result Result. * @param array> $contexts Contexts keyed by URL. * * @return array */ private static function contextForResult( CloudLinkHealthResult $result, array $contexts ): array { $context = $contexts[ $result->url() ] ?? array(); if ( $context !== array() ) { return $context; } return $result->context(); } /** * Build URL-keyed contexts from Cloud result rows. * * @param list $results Results. * * @return array> */ private static function contextsFromResults( array $results ): array { $contexts = array(); foreach ( $results as $result ) { $context = $result->context(); if ( $context !== array() ) { $contexts[ $result->url() ] = $context; } } return $contexts; } /** * Collect affiliate/detail URLs from saved products. * * @return array Product context keyed by URL. */ private function savedProductUrlContexts(): array { if ( $this->productRepository === null ) { return array(); } try { $result = $this->productRepository->findPaginated( new ProductListQuery( page: 1, perPage: 50, lifecycleState: ProductListQuery::LIFECYCLE_STATE_ACTIVE ) ); } catch ( Throwable $unused ) { unset( $unused ); return array(); } $contexts = array(); foreach ( $result->items() as $record ) { $url = $this->urlForProductRecord( $record ); if ( $url !== '' && ! array_key_exists( $url, $contexts ) ) { $contexts[ $url ] = array( 'product_id' => $record->id(), 'provider' => $record->provider(), 'provider_item_id' => $record->providerItemId(), 'label' => $this->labelForProductRecord( $record ), ); } } return $contexts; } /** * Collect custom button URLs from rendered affilun blocks in posts. * * @return array Context keyed by URL. */ private function renderedBlockUrlContexts(): array { $rows = ( $this->postRowsProvider )(); $contexts = array(); foreach ( $rows as $row ) { $postId = self::positiveInt( $row['ID'] ?? $row['id'] ?? null ); $title = is_scalar( $row['post_title'] ?? null ) ? (string) $row['post_title'] : '(タイトルなし)'; $content = is_string( $row['post_content'] ?? null ) ? $row['post_content'] : ''; if ( $postId === null || $content === '' ) { continue; } $label = sprintf( 'Post #%d / %s', $postId, $title ); foreach ( self::customButtonUrlsFromContent( $content ) as $url ) { if ( ! array_key_exists( $url, $contexts ) ) { $contexts[ $url ] = array( 'product_id' => null, 'label' => $label, ); } } foreach ( $this->productUrlContextsFromContent( $content, $label ) as $url => $context ) { if ( ! array_key_exists( $url, $contexts ) ) { $contexts[ $url ] = $context; } } } return $contexts; } /** * Merge primary saved-product contexts with post-level contexts. * * @param array> $primary Primary contexts. * @param array> $secondary Secondary contexts. * * @return array> */ private static function mergeLinkHealthContexts( array $primary, array $secondary ): array { $merged = $primary; foreach ( $secondary as $url => $context ) { if ( ! array_key_exists( $url, $merged ) ) { $merged[ $url ] = $context; continue; } $primaryLabel = is_scalar( $merged[ $url ]['label'] ?? null ) ? (string) $merged[ $url ]['label'] : ''; $secondaryLabel = is_scalar( $context['label'] ?? null ) ? (string) $context['label'] : ''; if ( $secondaryLabel !== '' && ! str_contains( $primaryLabel, $secondaryLabel ) ) { $merged[ $url ]['label'] = $primaryLabel === '' ? $secondaryLabel : $primaryLabel . ' / ' . $secondaryLabel; } if ( ! is_int( $merged[ $url ]['product_id'] ?? null ) && is_int( $context['product_id'] ?? null ) ) { $merged[ $url ]['product_id'] = $context['product_id']; } foreach ( array( 'provider', 'provider_item_id' ) as $key ) { if ( ! is_scalar( $merged[ $url ][ $key ] ?? null ) && is_scalar( $context[ $key ] ?? null ) ) { $merged[ $url ][ $key ] = (string) $context[ $key ]; } } } return $merged; } /** * Collect product URLs from affilun block product id attributes. * * @param string $content Post content. * @param string $postLabel Post label. * * @return array Context keyed by URL. */ private function productUrlContextsFromContent( string $content, string $postLabel ): array { if ( $this->productRepository === null ) { return array(); } $contexts = array(); foreach ( self::productIdsFromContent( $content ) as $productId ) { try { $record = $this->productRepository->findById( $productId ); } catch ( Throwable $unused ) { unset( $unused ); continue; } if ( $record === null ) { continue; } $url = $this->urlForProductRecord( $record ); if ( $url === '' || array_key_exists( $url, $contexts ) ) { continue; } $contexts[ $url ] = array( 'product_id' => $productId, 'provider' => $record->provider(), 'provider_item_id' => $record->providerItemId(), 'label' => sprintf( '%s / %s', $postLabel, $this->labelForProductRecord( $record ) ), ); } return $contexts; } /** * Extract product ids from affilun block content. * * @param string $content Post content. * * @return list */ private static function productIdsFromContent( string $content ): array { $attrsList = self::blockAttributesFromContent( $content ); $ids = array(); foreach ( $attrsList as $attrs ) { $id = self::positiveInt( $attrs['productId'] ?? null ); if ( $id !== null && ! in_array( $id, $ids, true ) ) { $ids[] = $id; } $productIds = is_array( $attrs['productIds'] ?? null ) ? $attrs['productIds'] : array(); foreach ( $productIds as $rawId ) { $id = self::positiveInt( $rawId ); if ( $id !== null && ! in_array( $id, $ids, true ) ) { $ids[] = $id; } } } return $ids; } /** * Extract custom button URLs from affilun block content. * * @param string $content Post content. * * @return list */ private static function customButtonUrlsFromContent( string $content ): array { $attrsList = self::blockAttributesFromContent( $content ); $urls = array(); foreach ( $attrsList as $attrs ) { $buttons = is_array( $attrs['customButtons'] ?? null ) ? $attrs['customButtons'] : array(); foreach ( $buttons as $button ) { if ( ! is_array( $button ) || ! is_scalar( $button['url'] ?? null ) ) { continue; } $url = trim( (string) $button['url'] ); if ( preg_match( '#\Ahttps?://#i', $url ) === 1 && ! in_array( $url, $urls, true ) ) { $urls[] = $url; } } } return $urls; } /** * Extract affilun block attributes from serialized content. * * @param string $content Post content. * * @return list> */ private static function blockAttributesFromContent( string $content ): array { if ( preg_match_all( '//s', $content, $matches ) === false ) { return array(); } $attrs = array(); foreach ( $matches[1] as $json ) { $decoded = json_decode( (string) $json, true ); if ( is_array( $decoded ) ) { $attrs[] = $decoded; } } return $attrs; } /** * Positive integer helper. * * @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 ) ) { $intValue = (int) $value; return $intValue > 0 ? $intValue : null; } return null; } /** * Pick the URL to check for one saved product. * * @param ProductRecord $record Product record. */ private function urlForProductRecord( ProductRecord $record ): string { $affiliateUrl = $record->affiliateUrl(); if ( is_string( $affiliateUrl ) && $affiliateUrl !== '' ) { return $affiliateUrl; } return $record->detailUrl(); } /** * Build a compact label for one saved product. * * @param ProductRecord $record Product record. */ private function labelForProductRecord( ProductRecord $record ): string { $id = $record->id(); return sprintf( '#%s %s / %s', $id === null ? '-' : (string) $id, $record->provider(), $record->title() ); } /** * Build default post rows provider. * * @return callable(): list> */ private static function defaultPostRowsProvider(): callable { return static function (): array { global $wpdb; if ( ! is_object( $wpdb ) || ! isset( $wpdb->posts ) || ! method_exists( $wpdb, 'get_results' ) ) { return array(); } $sql = "SELECT ID, post_title, post_content FROM {$wpdb->posts} WHERE post_status NOT IN ('trash', 'auto-draft') AND post_content LIKE '%wp:affilun/product%' ORDER BY post_modified_gmt DESC LIMIT 200"; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- Read-only bounded scan of local posts for AFFILUN block comments; table name is from $wpdb and no user input is interpolated. $rows = $wpdb->get_results( $sql, ARRAY_A ); return is_array( $rows ) ? $rows : array(); }; } /** * Render Cloud top-selling affiliate candidates. * * @param CloudSettings $cloud Cloud settings. * @param PluginSettings $settings Plugin settings. * @param array $query Query parameters. */ private function renderTopAffiliateCandidatesSection( CloudSettings $cloud, PluginSettings $settings, array $query ): void { echo '

' . esc_html( '売れ筋アフィリエイト候補' ) . '

'; echo '

' . esc_html( '有効化しているアフィリエイトプロバイダーに対応した売れ筋候補を affilun Cloud から取得し、候補専用テーブルに保存して表示します。この取得はCloud利用クォータを消費しません。' ) . '

'; if ( ! $cloud->isConfigured() ) { echo '

' . esc_html( '候補を取得するには、affilun Cloud に接続してください。' ) . '

'; return; } if ( ! self::cloudPlanAllowsLinkHealth( $cloud ) ) { echo '

'; echo esc_html( '売れ筋アフィリエイト候補は有料またはトライアル中の affilun Cloud プランで利用できます。' ); echo '

'; return; } if ( $this->topAffiliateCandidateRepository === null ) { echo '

'; echo esc_html( '候補保存テーブルを利用できません。プラグインのデータベースマイグレーションを確認してください。' ); echo '

'; return; } $providers = $this->enabledTopAffiliateProviders( $settings ); if ( $providers === array() ) { echo '

' . esc_html( '候補取得に使うアフィリエイトプロバイダーを設定画面で有効化してください。' ) . '

'; return; } echo '
'; echo ''; self::renderNonceField( self::topAffiliateRefreshNonceAction(), self::TOP_AFFILIATE_REFRESH_NONCE_FIELD ); echo ''; echo ' ' . esc_html( '対象: ' . implode( ', ', array_map( static fn ( string $provider ): string => self::CAMPAIGN_PROVIDERS[ $provider ] ?? $provider, $providers ) ) ) . ''; echo '
'; if ( self::queryString( $query, 'affilun_top_affiliate_refresh' ) === '1' ) { if ( ! self::verifyQueryNonce( $query, self::topAffiliateRefreshNonceAction(), self::TOP_AFFILIATE_REFRESH_NONCE_FIELD ) ) { echo '

'; echo esc_html( '売れ筋候補の更新リクエストを確認できませんでした。もう一度実行してください。' ); echo '

'; } else { try { $rows = $this->topAffiliateCandidates->fetch( $cloud, $providers, 50, time(), self::nonce() ); $inserted = $this->topAffiliateCandidateRepository->replaceAll( $rows ); echo '

'; echo esc_html( sprintf( '%d件の売れ筋候補を更新しました。', $inserted ) ); echo '

'; } catch ( Throwable $unused ) { unset( $unused ); echo '

'; echo esc_html( 'affilun Cloud の売れ筋候補取得に失敗しました。時間をおいて再実行してください。' ); echo '

'; } } } $currentPage = max( 1, self::queryInt( $query, 'affilun_top_affiliate_paged', 1 ) ); $perPage = self::queryPerPage( $query, self::TOP_AFFILIATE_PER_PAGE_PARAM, self::TOP_AFFILIATE_DEFAULT_GROUPS_PER_PAGE ); try { $records = $this->topAffiliateCandidateRecords( $providers ); $this->renderTopAffiliateCandidateTable( $records, $currentPage, $perPage ); } catch ( Throwable $unused ) { unset( $unused ); echo '

'; echo esc_html( '保存済み候補の読み込みに失敗しました。' ); echo '

'; } } /** * Load locally cached top affiliate candidates across repository pages. * * @param list $providers Enabled providers. * * @return list */ private function topAffiliateCandidateRecords( array $providers ): array { if ( $this->topAffiliateCandidateRepository === null ) { return array(); } $records = array(); $page = 1; do { $result = $this->topAffiliateCandidateRepository->findPaginated( new ProductListQuery( $page, 50, null, null, null, null, ProductListQuery::LIFECYCLE_STATE_ACTIVE, ProductListQuery::SORT_KEY_LAST_SYNCED_AT, ProductListQuery::SORT_ORDER_DESC ) ); foreach ( $result->items() as $record ) { if ( in_array( $record->provider(), $providers, true ) ) { $records[] = $record; } } $itemCount = count( $result->items() ); $recordCount = count( $records ); ++$page; } while ( $itemCount === 50 && $recordCount < 500 ); return $records; } /** * @param PluginSettings $settings Plugin settings. * * @return list */ private function enabledTopAffiliateProviders( PluginSettings $settings ): array { $providers = array(); foreach ( self::CAMPAIGN_PROVIDERS as $key => $label ) { unset( $label ); $provider = $settings->provider( $key ); if ( $provider !== null && $provider->enabled() && $provider->isConfigured() ) { $providers[] = $key; } } return $providers; } /** * @param list $records Candidate records. * @param int $currentPage Current page. * @param int $perPage Current page size. */ private function renderTopAffiliateCandidateTable( array $records, int $currentPage, int $perPage ): void { $groups = self::topAffiliateCandidateGroups( $records ); $totalGroups = count( $groups ); if ( $groups === array() ) { echo '

' . esc_html( '保存済みの売れ筋候補はありません。' ) . '

'; return; } $totalPages = max( 1, (int) ceil( $totalGroups / $perPage ) ); $currentPage = min( max( 1, $currentPage ), $totalPages ); $pageGroups = array_slice( $groups, ( $currentPage - 1 ) * $perPage, $perPage ); echo '
'; echo '
'; $this->renderTopAffiliatePagination( $currentPage, $totalPages, $totalGroups, $perPage ); echo '
'; echo ''; foreach ( array( 'プロバイダー', '商品名', '価格', '操作' ) as $heading ) { echo ''; } echo ''; foreach ( $pageGroups as $group ) { $record = self::preferredTopAffiliateCandidate( $group ); $image = $record->imagePrimaryUrl(); $productUrl = $record->affiliateUrl() ?? $record->detailUrl(); $title = $record->title(); $displayText = self::truncateText( $title, 72 ); $payload = wp_json_encode( self::topAffiliateCandidateSavePayload( $record, $group ) ); if ( ! is_string( $payload ) ) { $payload = '{}'; } echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html( $heading ) . '
' . esc_html( self::topAffiliateCandidateProviderLabel( $group ) ) . ''; echo '
'; if ( $image !== null && $image !== '' ) { echo ''; } else { echo ''; } echo '' . esc_html( $displayText ) . ''; echo '
'; echo '
' . esc_html( AdminPriceFormatter::formatProductRecord( $record ) ) . ''; echo ''; echo '
'; echo '
'; echo '
'; $this->renderTopAffiliatePagination( $currentPage, $totalPages, $totalGroups, $perPage ); echo '
'; echo '
'; $this->renderTopAffiliateCandidateScript(); } /** * Render top affiliate pagination. * * @param int $currentPage Current page. * @param int $totalPages Total pages. * @param int $totalGroups Total candidate groups. * @param int $perPage Current page size. */ private function renderTopAffiliatePagination( int $currentPage, int $totalPages, int $totalGroups, int $perPage ): void { echo '
'; echo '' . esc_html( sprintf( '%d件', $totalGroups ) ) . ' '; $this->renderPerPageLinks( self::TOP_AFFILIATE_PER_PAGE_PARAM, $perPage, static fn ( int $option ): string => self::topAffiliatePageUrl( 1, $option ) ); if ( $currentPage > 1 ) { echo '' . esc_html( '前へ' ) . ' '; } echo '' . esc_html( sprintf( '%d / %d', $currentPage, $totalPages ) ) . ' '; if ( $currentPage < $totalPages ) { echo '' . esc_html( '次へ' ) . ''; } echo '
'; } /** * @param list $records Candidate records. * * @return list> */ private static function topAffiliateCandidateGroups( array $records ): array { $groups = array(); foreach ( $records as $record ) { $groups[ $record->canonicalHash() ][] = $record; } $result = array(); foreach ( $groups as $group ) { if ( is_array( $group ) && $group !== array() ) { $result[] = self::sortTopAffiliateCandidateGroup( $group ); } } return $result; } /** * @param list $group Candidate group. * * @return list */ private static function sortTopAffiliateCandidateGroup( array $group ): array { usort( $group, static function ( ProductRecord $left, ProductRecord $right ): int { $rank = self::topAffiliateProviderRank( $left->provider() ) <=> self::topAffiliateProviderRank( $right->provider() ); if ( $rank !== 0 ) { return $rank; } return strcmp( $left->providerItemId(), $right->providerItemId() ); } ); return $group; } /** * @param list $group Candidate group. */ private static function preferredTopAffiliateCandidate( array $group ): ProductRecord { return self::sortTopAffiliateCandidateGroup( $group )[0]; } /** * Resolve display priority for a provider token. * * @param string $provider Provider token. */ private static function topAffiliateProviderRank( string $provider ): int { return self::TOP_AFFILIATE_PROVIDER_PRIORITY[ $provider ] ?? 1000; } /** * @param list $group Candidate group. */ private static function topAffiliateCandidateProviderLabel( array $group ): string { $labels = array(); foreach ( self::uniqueTopAffiliateProviderRecords( $group ) as $record ) { $labels[] = self::CAMPAIGN_PROVIDERS[ $record->provider() ] ?? $record->provider(); } return implode( ' / ', $labels ); } /** * Build the same REST payload used by the product-search add flow. * * @param ProductRecord $record Candidate record. * @param list $group Normalized candidate group. * * @return array */ private static function topAffiliateCandidateSavePayload( ProductRecord $record, array $group = array() ): array { $snapshot = array( 'title' => $record->title(), 'detail_url' => $record->detailUrl(), ); if ( $record->brand() !== null && $record->brand() !== '' ) { $snapshot['brand'] = $record->brand(); } if ( $record->affiliateUrl() !== null && $record->affiliateUrl() !== '' ) { $snapshot['affiliate_url'] = $record->affiliateUrl(); } if ( $record->imagePrimaryUrl() !== null && $record->imagePrimaryUrl() !== '' ) { $snapshot['image_primary_url'] = $record->imagePrimaryUrl(); } if ( $record->priceAmount() !== null && $record->priceCurrency() !== null ) { $price = array( 'amount' => $record->priceAmount(), 'currency' => $record->priceCurrency(), ); if ( $record->priceLabel() !== null && $record->priceLabel() !== '' ) { $price['label'] = $record->priceLabel(); } $snapshot['price'] = $price; } $payload = array( 'provider' => $record->provider(), 'provider_item_id' => $record->providerItemId(), 'snapshot' => $snapshot, ); $providerUrls = self::topAffiliateCandidateProviderUrls( $record, $group ); if ( $providerUrls !== array() ) { $payload['provider_urls'] = $providerUrls; } return $payload; } /** * @param ProductRecord $primary Primary product saved as snapshot. * @param list $group Normalized candidate group. * * @return list> */ private static function topAffiliateCandidateProviderUrls( ProductRecord $primary, array $group ): array { $records = self::uniqueTopAffiliateProviderRecords( $group === array() ? array( $primary ) : $group ); $urls = array(); foreach ( $records as $record ) { if ( $record->provider() === $primary->provider() && $record->providerItemId() === $primary->providerItemId() ) { continue; } $url = $record->affiliateUrl() ?? $record->detailUrl(); if ( $url === '' ) { continue; } $urls[] = array( 'provider_type' => $record->provider(), 'provider_item_id' => $record->providerItemId(), 'url' => $url, 'source' => 'cloud_top_affiliate_candidate', ); } return $urls; } /** * @param list $group Candidate group. * * @return list */ private static function uniqueTopAffiliateProviderRecords( array $group ): array { $records = array(); $seen = array(); foreach ( self::sortTopAffiliateCandidateGroup( $group ) as $record ) { if ( array_key_exists( $record->provider(), $seen ) ) { continue; } $seen[ $record->provider() ] = true; $records[] = $record; } return $records; } /** * Truncate visible table text without changing the link target/title. * * @param string $text Text to truncate. * @param int $limit Maximum visible characters. */ private static function truncateText( string $text, int $limit ): string { if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) { return mb_strlen( $text, 'UTF-8' ) > $limit ? mb_substr( $text, 0, $limit, 'UTF-8' ) . '…' : $text; } return strlen( $text ) > $limit ? substr( $text, 0, $limit ) . '...' : $text; } /** * Enqueue the add-candidate interaction script. */ private function renderTopAffiliateCandidateScript(): void { $strings = array( 'adding' => '追加中…', 'add' => '追加', 'added' => '追加しました (#%d)', 'failed' => '追加に失敗しました。', 'no_api' => 'wp.apiFetch が読み込まれていないため、商品を追加できません。', 'invalid' => '候補データが不正なため追加できません。', ); $stringsJs = wp_json_encode( $strings ); if ( ! is_string( $stringsJs ) ) { $stringsJs = '{}'; } $script = '(function(){' . 'var STRINGS=' . $stringsJs . ';' . <<<'JS' function format(tpl,n){return tpl.replace('%d',String(n));} function extractSavedId(response){ if(response&&response.data){ var d=response.data; if(typeof d.id==='number'&&d.id>0)return d.id; if(d.product&&typeof d.product.id==='number'&&d.product.id>0)return d.product.id; } if(response&&typeof response.id==='number'&&response.id>0)return response.id; return null; } function statusFor(btn){ var parent=btn.parentNode; if(!parent)return null; return parent.querySelector('.affilun-top-affiliate-add-status'); } function setStatus(btn,text,isError){ var el=statusFor(btn); if(!el)return; el.textContent=text||''; el.style.color=isError?'#b32d2e':''; } function payloadFor(btn){ try { var payload=JSON.parse(btn.getAttribute('data-affilun-product-payload')||'{}'); if(!payload||typeof payload!=='object'||!payload.provider||!payload.provider_item_id||!payload.snapshot||!payload.snapshot.title||!payload.snapshot.detail_url)return null; return payload; } catch(e) { return null; } } function onAddClick(btn){ if(!window.wp||typeof window.wp.apiFetch!=='function'){ setStatus(btn,STRINGS.no_api,true); return; } var payload=payloadFor(btn); if(payload===null){ setStatus(btn,STRINGS.invalid,true); return; } btn.disabled=true; btn.textContent=STRINGS.adding; setStatus(btn,'',false); window.wp.apiFetch({path:'/affilun/v1/products',method:'POST',data:payload}).then(function(response){ var id=extractSavedId(response); if(id===null){ btn.disabled=false; btn.textContent=STRINGS.add; setStatus(btn,STRINGS.failed,true); return; } btn.textContent=format(STRINGS.added,id); }).catch(function(err){ btn.disabled=false; btn.textContent=STRINGS.add; var msg=STRINGS.failed; if(err&&typeof err.message==='string'&&err.message!=='')msg=err.message; else if(err&&err.error&&typeof err.error.message==='string')msg=err.error.message; setStatus(btn,msg,true); }); } function init(){ var buttons=document.querySelectorAll('.affilun-top-affiliate-add'); for(var i=0;i $query Query parameters. */ private function renderAiScanSection( CloudSettings $cloud, array $query ): void { echo '

' . esc_html( 'AIスキャン機能' ) . '

'; echo '

' . esc_html( 'アフィリエイト未設置の記事を affilun Cloud で解析し、有効化済みプロバイダーの商品検索結果から affilun 商品カードを記事末尾へ追加します。' ) . '

'; if ( ! $cloud->isConfigured() ) { echo '

'; echo esc_html( 'affilun Cloud が未設定です。設定画面で Site ID と API Key を保存してください。' ); echo '

'; return; } $this->renderAiScanNotice( $query ); $this->renderAiScanJobStatus( $query ); $filter = self::cleanAiScanFilter( self::queryString( $query, 'affilun_ai_scan_filter' ) ); $currentPage = max( 1, self::queryInt( $query, 'affilun_ai_scan_paged', 1 ) ); $perPage = self::queryPerPage( $query, self::AI_SCAN_PER_PAGE_PARAM, self::AI_SCAN_DEFAULT_ROWS_PER_PAGE ); $allRows = $this->filteredAiScanRows( $filter ); $totalRows = count( $allRows ); $totalPages = max( 1, (int) ceil( $totalRows / $perPage ) ); $currentPage = min( $currentPage, $totalPages ); $rows = array_slice( $allRows, ( $currentPage - 1 ) * $perPage, $perPage ); echo '
'; echo ''; if ( $perPage !== self::AI_SCAN_DEFAULT_ROWS_PER_PAGE ) { echo ''; } echo ''; echo ' '; echo ''; echo '
'; echo '
'; echo ''; echo ''; echo ''; self::renderNonceField( self::AI_SCAN_NONCE_ACTION, self::AI_SCAN_NONCE_FIELD ); echo '
'; echo ''; echo '
'; $this->renderAiScanPagination( $filter, $currentPage, $totalPages, $totalRows, $perPage ); echo '
'; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; if ( $rows === array() ) { echo ''; } $noCandidateMap = $this->aiScanNoCandidateMap(); foreach ( $rows as $row ) { $postId = self::positiveInt( $row['id'] ?? null ) ?? 0; $title = self::stringValue( $row['title'] ?? '' ); $status = self::aiScanRowStatus( $row, $noCandidateMap ); $canScan = $status === '-'; $statusText = self::postStatusLabel( self::stringValue( $row['status'] ?? '' ) ); $date = self::stringValue( $row['date'] ?? '' ); $titleUrl = self::postEditUrl( $postId, self::stringValue( $row['url'] ?? '' ) ); echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html( 'タイトル' ) . '' . esc_html( 'ステータス' ) . '' . esc_html( '日付' ) . '' . esc_html( 'アフィリエイト' ) . '
' . esc_html( '対象の記事はありません。' ) . '
'; if ( $canScan ) { echo ''; } echo ''; if ( $titleUrl !== '' ) { echo '' . esc_html( $title !== '' ? $title : '(no title)' ) . ''; } else { echo esc_html( $title !== '' ? $title : '(no title)' ); } echo '' . esc_html( $statusText ) . '' . esc_html( $date ) . '' . esc_html( $status ) . '
'; echo '
'; $this->renderAiScanPagination( $filter, $currentPage, $totalPages, $totalRows, $perPage ); echo '
'; echo '
'; } /** * Render AI scan pagination. * * @param string $filter Current filter. * @param int $currentPage Current page. * @param int $totalPages Total pages. * @param int $totalRows Total filtered rows. * @param int $perPage Current page size. */ private function renderAiScanPagination( string $filter, int $currentPage, int $totalPages, int $totalRows, int $perPage ): void { echo '
'; echo '' . esc_html( sprintf( '%d件', $totalRows ) ) . ' '; $this->renderPerPageLinks( self::AI_SCAN_PER_PAGE_PARAM, $perPage, static fn ( int $option ): string => self::aiScanPageUrl( $filter, 1, $option ) ); if ( $currentPage > 1 ) { echo '' . esc_html( '前へ' ) . ' '; } echo '' . esc_html( sprintf( '%d / %d', $currentPage, $totalPages ) ) . ' '; if ( $currentPage < $totalPages ) { echo '' . esc_html( '次へ' ) . ''; } echo '
'; } /** * Render 10/20/50 page-size links. * * @param string $paramName Current page-size parameter name. * @param int $current Current page size. * @param callable(int):string $urlBuilder URL builder for an option. */ private function renderPerPageLinks( string $paramName, int $current, callable $urlBuilder ): void { echo ''; echo esc_html( '表示件数: ' ); $first = true; foreach ( self::PER_PAGE_OPTIONS as $option ) { if ( ! $first ) { echo ' / '; } if ( $option === $current ) { echo '' . esc_html( sprintf( '%d件', $option ) ) . ''; } else { echo '' . esc_html( sprintf( '%d件', $option ) ) . ''; } $first = false; } echo ' '; } /** * @param array $query Query parameters. */ private function renderAiScanNotice( array $query ): void { if ( self::queryString( $query, 'affilun_ai_scan_queued' ) === '1' ) { $selected = self::queryInt( $query, 'affilun_ai_scan_selected', 0 ); if ( $selected <= 0 ) { echo '

'; echo esc_html( 'AIスキャン対象の記事が選択されていません。' ); echo '

'; return; } echo '

'; echo esc_html( sprintf( 'AIスキャンジョブを開始しました。対象: %d件。処理はバックグラウンドで少しずつ進みます。', $selected ) ); echo '

'; return; } if ( self::queryString( $query, 'affilun_ai_scan_done' ) !== '1' ) { return; } $selected = self::queryInt( $query, 'affilun_ai_scan_selected', 0 ); $scanned = self::queryInt( $query, 'affilun_ai_scan_scanned', 0 ); $updated = self::queryInt( $query, 'affilun_ai_scan_updated', 0 ); $noCandidate = self::queryInt( $query, 'affilun_ai_scan_no_candidate', 0 ); $skipped = self::queryInt( $query, 'affilun_ai_scan_skipped', 0 ); $failed = self::queryInt( $query, 'affilun_ai_scan_failed', 0 ); if ( $selected <= 0 && $scanned <= 0 && $updated <= 0 && $noCandidate <= 0 && $skipped <= 0 && $failed <= 0 ) { echo '

'; echo esc_html( 'AIスキャン対象の記事が選択されていません。' ); echo '

'; return; } echo '

'; echo esc_html( sprintf( 'AIスキャンを実行しました。選択: %d件、解析: %d件、追加: %d件、商品候補無し: %d件、スキップ: %d件、失敗: %d件。', $selected, $scanned, $updated, $noCandidate, $skipped, $failed ) ); echo '

'; } /** * Render the latest or requested background AI scan job status. * * @param array $query Query parameters. */ private function renderAiScanJobStatus( array $query ): void { $job = $this->aiScanJobForQuery( $query ); if ( $job === null ) { return; } $status = self::stringValue( $job['status'] ?? '' ); if ( $status === '' ) { return; } $done = self::nonNegativeInt( $job['progress_done'] ?? 0 ); $total = self::nonNegativeInt( $job['progress_total'] ?? 0 ); $result = isset( $job['result'] ) && is_array( $job['result'] ) ? $job['result'] : array(); $class = $status === 'succeeded' ? 'notice-success' : ( $status === 'failed' ? 'notice-error' : 'notice-info' ); echo '

'; echo esc_html( sprintf( 'AIスキャンジョブ: %s (%d / %d件)。解析: %d件、追加: %d件、商品候補無し: %d件、スキップ: %d件、失敗: %d件。', self::aiScanJobStatusLabel( $status ), $done, $total, self::nonNegativeInt( $result['scanned'] ?? 0 ), self::nonNegativeInt( $result['updated'] ?? 0 ), self::nonNegativeInt( $result['no_candidate'] ?? 0 ), self::nonNegativeInt( $result['skipped'] ?? 0 ), self::nonNegativeInt( $result['failed'] ?? 0 ) ) ); if ( $status === 'queued' || $status === 'running' ) { echo ' '; echo esc_html( 'しばらくしてからこのページを再読み込みすると進捗を確認できます。' ); } $lastError = self::stringValue( $job['last_error'] ?? '' ); if ( $lastError !== '' ) { echo '
'; echo esc_html( '最新エラー: ' . $lastError ); } echo '

'; } /** * @param array $query Query parameters. * * @return array|null */ private function aiScanJobForQuery( array $query ): ?array { if ( $this->optionStore === null ) { return null; } $jobs = $this->optionStore->get( CloudAiPostScanRunner::JOBS_OPTION, array() ); if ( ! is_array( $jobs ) || $jobs === array() ) { return null; } $jobId = self::queryString( $query, 'affilun_ai_scan_job_id' ); if ( $jobId !== '' && isset( $jobs[ $jobId ] ) && is_array( $jobs[ $jobId ] ) ) { return $jobs[ $jobId ]; } foreach ( $jobs as $job ) { $status = is_array( $job ) ? self::stringValue( $job['status'] ?? '' ) : ''; if ( $status === 'queued' || $status === 'running' ) { return $job; } } return null; } /** * Human-readable background AI scan status. * * @param string $status Stored job status. */ private static function aiScanJobStatusLabel( string $status ): string { return match ( $status ) { 'queued' => '待機中', 'running' => '実行中', 'succeeded' => '完了', 'failed' => '失敗', default => $status, }; } /** * @param mixed $value Candidate value. */ private static function nonNegativeInt( mixed $value ): int { return is_int( $value ) ? max( 0, $value ) : ( is_numeric( $value ) ? max( 0, (int) $value ) : 0 ); } /** * Filter AI scan post rows by affiliate status. * * @param string $filter Status filter. * * @return list> */ private function filteredAiScanRows( string $filter ): array { $rows = ( $this->aiScanPostRowsProvider )(); $noCandidateMap = $this->aiScanNoCandidateMap(); $filtered = array(); foreach ( $rows as $row ) { $status = self::aiScanRowStatus( $row, $noCandidateMap ); if ( $filter === self::AI_SCAN_FILTER_ALL || ( $filter === self::AI_SCAN_FILTER_INSTALLED && $status === '設置済み' ) || ( $filter === self::AI_SCAN_FILTER_MISSING && $status === '-' ) || ( $filter === self::AI_SCAN_FILTER_NO_CANDIDATE && $status === '商品候補無し' ) ) { $filtered[] = $row; } } return $filtered; } /** * @return array */ private function aiScanNoCandidateMap(): array { if ( $this->optionStore === null ) { return array(); } $value = $this->optionStore->get( CloudAiPostScanRunner::NO_CANDIDATE_OPTION, array() ); if ( ! is_array( $value ) ) { return array(); } $map = array(); foreach ( $value as $key => $timestamp ) { $id = self::positiveInt( $key ); if ( $id !== null && is_scalar( $timestamp ) ) { $map[ (string) $id ] = trim( (string) $timestamp ); } } return $map; } /** * @param array $row Post row. * @param array $noCandidateMap No-candidate post map. */ private static function aiScanRowStatus( array $row, array $noCandidateMap ): string { $postId = self::positiveInt( $row['id'] ?? null ); $content = self::stringValue( $row['content'] ?? '' ); if ( $content !== '' && CloudAiPostScanRunner::hasAffiliateContent( $content ) ) { return '設置済み'; } if ( $postId !== null && array_key_exists( (string) $postId, $noCandidateMap ) ) { return '商品候補無し'; } return '-'; } /** * @return array */ private static function aiScanFilterLabels(): array { return array( self::AI_SCAN_FILTER_ALL => 'すべて', self::AI_SCAN_FILTER_MISSING => '未設置', self::AI_SCAN_FILTER_INSTALLED => '設置済み', self::AI_SCAN_FILTER_NO_CANDIDATE => '商品候補無し', ); } /** * Clean AI scan status filter. * * @param string $value Raw filter value. */ private static function cleanAiScanFilter( string $value ): string { return array_key_exists( $value, self::aiScanFilterLabels() ) ? $value : self::AI_SCAN_FILTER_MISSING; } /** * @return callable(): list> */ private static function defaultAiScanPostRowsProvider(): callable { return static function (): array { if ( ! function_exists( 'get_posts' ) ) { return array(); } $posts = get_posts( array( 'post_type' => 'post', 'post_status' => array( 'publish', 'draft', 'pending', 'future', 'private' ), 'posts_per_page' => -1, 'no_found_rows' => true, 'orderby' => 'date', 'order' => 'DESC', ) ); $rows = array(); foreach ( $posts as $post ) { $rows[] = array( 'id' => (int) $post->ID, 'title' => (string) $post->post_title, 'status' => (string) $post->post_status, 'date' => (string) $post->post_date, 'content' => (string) $post->post_content, 'url' => self::postEditUrl( (int) $post->ID ), ); } return $rows; }; } /** * Build post edit URL. * * @param int $postId Post id. * @param string $fallback Fallback URL. */ private static function postEditUrl( int $postId, string $fallback = '' ): string { if ( $fallback !== '' ) { return preg_match( '#\Ahttps?://#i', $fallback ) === 1 ? $fallback : self::resolveAdminUrl( $fallback ); } if ( $postId <= 0 ) { return ''; } if ( function_exists( 'get_edit_post_link' ) ) { $url = get_edit_post_link( $postId, '' ); if ( is_string( $url ) && $url !== '' ) { return $url; } } return self::resolveAdminUrl( 'post.php?post=' . rawurlencode( (string) $postId ) . '&action=edit' ); } /** * Build AI scan page URL. * * @param string $filter Filter. * @param int $page Page number. * @param int $perPage Page size. */ private static function aiScanPageUrl( string $filter, int $page, int $perPage ): string { $args = array( 'page' => 'affilun-cloud-tools', 'affilun_ai_scan_filter' => $filter, 'affilun_ai_scan_paged' => (string) max( 1, $page ), ); if ( $perPage !== self::AI_SCAN_DEFAULT_ROWS_PER_PAGE ) { $args[ self::AI_SCAN_PER_PAGE_PARAM ] = (string) $perPage; } $url = self::resolveAdminUrl( 'admin.php' ); if ( function_exists( 'add_query_arg' ) ) { return (string) add_query_arg( $args, $url ); } return $url . '?' . http_build_query( $args ); } /** * Build top affiliate candidate page URL. * * @param int $page Page number. * @param int $perPage Page size. */ private static function topAffiliatePageUrl( int $page, int $perPage ): string { $args = array( 'page' => 'affilun-cloud-tools', 'affilun_top_affiliate_paged' => (string) max( 1, $page ), ); if ( $perPage !== self::TOP_AFFILIATE_DEFAULT_GROUPS_PER_PAGE ) { $args[ self::TOP_AFFILIATE_PER_PAGE_PARAM ] = (string) $perPage; } $url = self::resolveAdminUrl( 'admin.php' ); if ( function_exists( 'add_query_arg' ) ) { return (string) add_query_arg( $args, $url ); } return $url . '?' . http_build_query( $args ); } /** * Render Japanese post status label. * * @param string $status WordPress post status. */ private static function postStatusLabel( string $status ): string { return match ( $status ) { 'publish' => '公開済み', 'draft' => '下書き', 'pending' => 'レビュー待ち', 'future' => '予約済み', 'private' => '非公開', default => $status !== '' ? $status : '-', }; } /** * Render campaign section. * * @param CloudSettings $cloud Cloud settings. * @param PluginSettings $settings Plugin settings. * @param array $query Query parameters. */ private function renderCampaignSection( CloudSettings $cloud, PluginSettings $settings, array $query ): void { echo '

' . esc_html( 'キャンペーン提案' ) . '

'; echo '

' . esc_html( '提携先キャンペーンに該当する保存商品と、追加候補の商品を表示します。リンク監視とは分離し、自動公開や自動保存は行いません。' ) . '

'; $providerOptions = $this->campaignProviderOptions( $settings ); $provider = self::queryString( $query, 'affilun_campaign_provider' ); if ( ! array_key_exists( $provider, $providerOptions ) ) { $provider = array_key_first( $providerOptions ) ?: 'amazon'; } $date = self::queryString( $query, 'affilun_campaign_date' ); if ( $date === '' ) { $date = gmdate( 'Y-m-d' ); } echo '
'; echo ' '; echo ' '; echo ' '; self::renderNonceField( self::revenueRefreshNonceAction(), self::REVENUE_REFRESH_NONCE_FIELD ); echo '
'; $this->renderRevenueStatusFlash( $query ); if ( ! $cloud->isConfigured() || ! isset( $query['affilun_campaign_provider'] ) ) { return; } $products = $this->campaignProducts( $provider ); try { if ( ! self::verifyQueryNonce( $query, self::revenueRefreshNonceAction(), self::REVENUE_REFRESH_NONCE_FIELD ) ) { echo '

'; echo esc_html( 'キャンペーン提案の更新リクエストを確認できませんでした。もう一度実行してください。' ); echo '

'; return; } $suggestions = $this->campaigns->fetchOpportunities( $cloud, $provider, $date, $products, time(), self::nonce() ); $this->renderCampaignSuggestions( $suggestions ); } catch ( Throwable $unused ) { unset( $unused ); echo '

'; echo esc_html( 'affilun Cloud のキャンペーン提案取得に失敗しました。' ); echo '

'; } } /** * Render a status-update flash for the opportunity work queue. * * @param array $query Query parameters. */ private function renderRevenueStatusFlash( array $query ): void { if ( self::queryString( $query, 'affilun_revenue_status_updated' ) === '1' ) { echo '

'; echo esc_html( 'キャンペーン提案の状態を更新しました。' ); echo '

'; return; } if ( self::queryString( $query, 'affilun_revenue_status_error' ) === '1' ) { echo '

'; echo esc_html( 'キャンペーン提案の状態更新に失敗しました。時間をおいて再実行してください。' ); echo '

'; } } /** * Resolve campaign provider options from enabled providers. * * @param PluginSettings $settings Plugin settings. * * @return array */ private function campaignProviderOptions( PluginSettings $settings ): array { $options = array(); foreach ( self::CAMPAIGN_PROVIDERS as $key => $label ) { $provider = $settings->provider( $key ); if ( $provider !== null && $provider->enabled() && $provider->isConfigured() ) { $options[ $key ] = $label; } } return $options !== array() ? $options : self::CAMPAIGN_PROVIDERS; } /** * Build saved-product context for campaign opportunities. * * @param string $provider Provider. * * @return list> */ private function campaignProducts( string $provider ): array { if ( $this->productRepository === null ) { return array(); } $result = $this->productRepository->findPaginated( new ProductListQuery( 1, 50, $provider, null, null, null, ProductListQuery::LIFECYCLE_STATE_ACTIVE, ProductListQuery::SORT_KEY_UPDATED_AT, ProductListQuery::SORT_ORDER_DESC ) ); $products = array(); foreach ( $result->items() as $record ) { $metadata = array(); if ( $record->id() !== null ) { $metadata['wp_product_id'] = $record->id(); } if ( $record->priceAmount() !== null ) { $metadata['price_amount'] = $record->priceAmount(); } if ( $record->availabilityStatus() !== null ) { $metadata['availability_status'] = $record->availabilityStatus(); } $products[] = array( 'provider' => $record->provider(), 'provider_item_id' => $record->providerItemId(), 'title' => $record->title(), 'detail_url' => $record->detailUrl(), 'affiliate_url' => $record->affiliateUrl(), 'metadata' => $metadata, ); } return $products; } /** * Build a nonce action for one opportunity status transition. * * @param string $opportunityId Opportunity id. * @param string $status New status. */ public static function revenueOpportunityStatusNonceAction( string $opportunityId, string $status ): string { return 'affilun_revenue_opportunity_status_' . $opportunityId . '_' . $status; } /** * Build a nonce action for refreshing one link-health job. * * @param string $jobId Job id. */ private static function linkHealthJobNonceAction( string $jobId ): string { return 'affilun_link_health_job_' . $jobId; } /** * Build a nonce action for starting saved-link health scans. * * @param string $pageSlug Admin page slug. */ private static function linkHealthSavedNonceAction( string $pageSlug ): string { return 'affilun_link_health_saved_' . $pageSlug; } /** * Build a nonce action for refreshing revenue opportunities. */ private static function revenueRefreshNonceAction(): string { return 'affilun_revenue_opportunities_refresh'; } /** * Build a nonce action for refreshing top affiliate candidates. */ private static function topAffiliateRefreshNonceAction(): string { return 'affilun_top_affiliate_candidates_refresh'; } /** * Render campaign suggestions. * * @param list $suggestions Suggestions. */ private function renderCampaignSuggestions( array $suggestions ): void { if ( $suggestions === array() ) { echo '

' . esc_html( 'キャンペーン候補はありません。' ) . '

'; return; } $hasAddableProduct = false; echo ''; foreach ( array( 'Provider', 'Type', 'Label', 'Microcopy', 'Period', 'Offer', '操作' ) as $heading ) { echo ''; } echo ''; foreach ( $suggestions as $suggestion ) { echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html( $heading ) . '
' . esc_html( $suggestion->provider() ) . '' . esc_html( $suggestion->isProductOpportunity() ? '保存商品' : '共有キャンペーン' ) . '' . esc_html( $suggestion->label() ) . '' . esc_html( $suggestion->microcopy() ) . '' . esc_html( $suggestion->startAt() . ' - ' . $suggestion->endAt() ) . '' . esc_html( self::campaignOfferSummary( $suggestion ) ) . ''; echo ' '; echo ' '; $productId = $suggestion->wpProductId(); if ( $productId !== null ) { echo '' . esc_html( '商品を開く' ) . ''; } elseif ( $suggestion->isAddableProduct() ) { $hasAddableProduct = true; echo ''; echo '
'; } echo '
'; if ( $hasAddableProduct ) { $this->renderTopAffiliateCandidateScript(); } } /** * Build an add-product payload for a campaign suggestion. * * @param CloudCampaignSuggestion $suggestion Suggestion. */ private static function campaignProductPayload( CloudCampaignSuggestion $suggestion ): string { $offer = $suggestion->offer(); $price = array(); $amount = is_scalar( $offer['price_amount'] ?? null ) ? trim( (string) $offer['price_amount'] ) : ''; $currency = is_scalar( $offer['currency'] ?? null ) ? trim( (string) $offer['currency'] ) : ''; if ( $amount !== '' ) { $price = array( 'amount' => $amount, 'currency' => $currency !== '' ? $currency : 'JPY', 'label' => AdminPriceFormatter::format( $amount, $currency !== '' ? $currency : 'JPY' ), ); } $snapshot = array( 'title' => $suggestion->title(), 'detail_url' => $suggestion->detailUrl(), ); if ( $suggestion->affiliateUrl() !== '' ) { $snapshot['affiliate_url'] = $suggestion->affiliateUrl(); } if ( $price !== array() ) { $snapshot['price'] = $price; } $payload = wp_json_encode( array( 'provider' => $suggestion->provider(), 'provider_item_id' => $suggestion->providerItemId(), 'snapshot' => $snapshot, ) ); return is_string( $payload ) ? $payload : '{}'; } /** * Summarize provider offer metadata for the campaign table. * * @param CloudCampaignSuggestion $suggestion Suggestion. */ private static function campaignOfferSummary( CloudCampaignSuggestion $suggestion ): string { $parts = array(); if ( $suggestion->providerItemId() !== '' ) { $parts[] = $suggestion->providerItemId(); } $offer = $suggestion->offer(); if ( isset( $offer['deal_type'] ) && is_scalar( $offer['deal_type'] ) && (string) $offer['deal_type'] !== '' ) { $parts[] = (string) $offer['deal_type']; } if ( isset( $offer['savings_percentage'] ) && is_scalar( $offer['savings_percentage'] ) ) { $parts[] = sprintf( '%s%% off', (string) $offer['savings_percentage'] ); } if ( isset( $offer['price_amount'] ) && is_scalar( $offer['price_amount'] ) ) { $currency = is_scalar( $offer['currency'] ?? null ) && (string) $offer['currency'] !== '' ? (string) $offer['currency'] : 'JPY'; $parts[] = AdminPriceFormatter::format( (string) $offer['price_amount'], $currency ); } if ( $suggestion->source() !== '' ) { $parts[] = $suggestion->source(); } return $parts !== array() ? implode( ' / ', $parts ) : '-'; } /** * Resolve an admin URL via WordPress when available. * * @param string $path Admin path. */ private static function resolveAdminUrl( string $path ): string { if ( function_exists( 'admin_url' ) ) { return (string) admin_url( $path ); } return $path; } /** * Render a nonce field with a pure-unit fallback. * * @param string $action Nonce action. * @param string $name Field name. */ private static function renderNonceField( string $action, string $name ): void { if ( function_exists( 'wp_nonce_field' ) ) { wp_nonce_field( $action, $name ); return; } echo ''; } /** * Verify a nonce from query parameters. * * @param array $query Query parameters. * @param string $action Expected nonce action. * @param string $field Nonce field. */ private static function verifyQueryNonce( array $query, string $action, string $field ): bool { $nonce = self::queryString( $query, $field ); if ( $nonce === '' || ! function_exists( 'wp_verify_nonce' ) ) { return false; } return (bool) wp_verify_nonce( $nonce, $action ); } /** * Product edit admin URL. * * @param int $productId Product id. */ private static function productEditUrl( int $productId ): string { $url = self::resolveAdminUrl( 'admin.php?page=affilun-edit-product&product_id=' . rawurlencode( (string) $productId ) ); if ( function_exists( 'add_query_arg' ) && function_exists( 'wp_create_nonce' ) ) { $url = add_query_arg( array( '_affilun_edit_product_nonce' => wp_create_nonce( 'affilun_edit_product_' . $productId ), ), $url ); } return $url; } /** * Read scalar query value. * * @param array $query Query. * @param string $key Key. */ private static function queryString( array $query, string $key ): string { $value = $query[ $key ] ?? ''; return is_scalar( $value ) ? trim( (string) $value ) : ''; } /** * Read non-negative integer query value. * * @param array $query Query. * @param string $key Key. * @param int $fallback Fallback value. */ private static function queryInt( array $query, string $key, int $fallback ): int { $value = self::queryString( $query, $key ); if ( $value === '' || ! ctype_digit( $value ) ) { return $fallback; } return (int) $value; } /** * Read a page-size query value restricted to the exposed choices. * * @param array $query Query. * @param string $key Key. * @param int $fallback Fallback page size. */ private static function queryPerPage( array $query, string $key, int $fallback ): int { $value = self::queryInt( $query, $key, $fallback ); return in_array( $value, self::PER_PAGE_OPTIONS, true ) ? $value : $fallback; } /** * Convert scalar value to string. * * @param mixed $value Candidate value. */ private static function stringValue( mixed $value ): string { return is_scalar( $value ) ? trim( (string) $value ) : ''; } /** * Generate request nonce. * * @throws \Exception When random generation fails. */ private static function nonce(): string { return bin2hex( random_bytes( 8 ) ); } }