*/ private const ARTICLE_DRAFT_LENGTH_CHARS = array( 'short' => 200, 'medium' => 400, 'long' => 600, ); /** * Fallback article lead copy by tone token. * * @var array */ private const ARTICLE_DRAFT_TONE_LEADS = array( 'professional_blog' => 'プロの商品紹介ライターの視点で、推しポイントを中心に整理します。', 'friendly' => '新しい物好きの友人におすすめするように、良さが伝わるポイントを紹介します。', 'review_comparison' => 'プロのバイヤーが同ジャンル製品と比較する目線で、注目点を整理します。', 'concise' => 'マーケティング担当者が売り込み前に押さえたい要点として、推しポイントをまとめます。', ); /** * Transport callback. * * @var callable */ private $transport; /** * @param CloudRequestSigner $signer Request signer. * @param callable|null $transport Transport override. * * @phpstan-param (callable(string,array,string):array)|null $transport */ public function __construct( private CloudRequestSigner $signer = new CloudRequestSigner(), ?callable $transport = null ) { $this->transport = $transport ?? self::defaultTransport(); } /** * Generate an editable article draft. * * @param CloudSettings $settings Cloud settings. * @param array $payload Product and article options. * @param int $timestamp Timestamp. * @param string $nonce Nonce. * * @return array * * @throws InvalidArgumentException When Cloud settings are incomplete. * @throws RuntimeException When Cloud response fails. */ public function generate( CloudSettings $settings, array $payload, int $timestamp, string $nonce ): array { if ( ! $settings->isConfigured() ) { throw new InvalidArgumentException( 'affilun Cloud settings are not configured.' ); } $payload = self::normalizePayload( $payload ); $path = '/ai/product-article-draft'; $body = self::jsonEncode( $payload ); $headers = $this->signer->sign( $settings->siteId(), $settings->apiKey(), 'POST', $path, $body, $timestamp, $nonce ); $headers['Content-Type'] = 'application/json'; $response = ( $this->transport )( $settings->endpoint() . $path, $headers, $body ); $status = isset( $response['status'] ) ? (int) $response['status'] : 0; $data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : null; if ( $status < 200 || $status >= 300 || ( $data['ok'] ?? true ) === false ) { $error = AiSuggestionException::fromCloudResponse( $status, $data ); if ( ( $status === 0 || $status >= 500 ) && in_array( $error->errorCode(), array( 'affilun_cloud_unavailable', 'affilun_cloud_invalid_response' ), true ) ) { $fallback = self::fallbackDraft( $payload ); $fallback['provider_unavailable'] = true; $fallback['fallback_error_code'] = $error->errorCode(); return $fallback; } throw $error; } if ( $data === null || ! is_string( $data['title'] ?? null ) || trim( $data['title'] ) === '' || ! is_string( $data['body_markdown'] ?? null ) || trim( $data['body_markdown'] ) === '' ) { throw new AiSuggestionException( 'affilun_cloud_invalid_response', 'affilun Cloud returned an invalid article draft.', 503, array(), true ); } return $data; } /** * Bound free text before signing; never shorten an identifier or URL. * * @param array $payload Product and generation options. * @return array */ public static function normalizePayload( array $payload ): array { $limits = array( 'provider' => 80, 'provider_item_id' => 255, 'title' => 500, 'brand' => 255, 'description' => 4000, 'rating' => 32, 'locale' => 20, 'tone' => 80, 'target_length' => 32, 'detail_url' => 2048, 'affiliate_url' => 2048, ); foreach ( $limits as $key => $limit ) { if ( ! isset( $payload[ $key ] ) ) { continue; } $value = $payload[ $key ]; if ( ! is_string( $value ) || preg_match( '//u', $value ) !== 1 ) { throw new AiSuggestionException( 'affilun_validation_failed', 'Product fields must contain valid UTF-8 text.', 422 ); } $bounded = self::boundedText( $value, $limit ); if ( $bounded !== $value && in_array( $key, array( 'provider', 'provider_item_id', 'detail_url', 'affiliate_url' ), true ) ) { throw new AiSuggestionException( 'affilun_validation_failed', 'The product identifier or URL exceeds the Cloud limit.', 422 ); } $payload[ $key ] = $bounded; } foreach ( array( 'provider', 'title' ) as $key ) { if ( ! is_string( $payload[ $key ] ?? null ) || trim( $payload[ $key ] ) === '' ) { throw new AiSuggestionException( 'affilun_validation_failed', 'Product provider and title are required.', 422 ); } } if ( isset( $payload['review_urls'] ) ) { if ( ! is_array( $payload['review_urls'] ) || count( $payload['review_urls'] ) > 20 ) { throw new AiSuggestionException( 'affilun_validation_failed', 'At most 20 review URLs are allowed.', 422 ); } foreach ( $payload['review_urls'] as $url ) { if ( ! is_string( $url ) || preg_match( '//u', $url ) !== 1 || self::boundedText( $url, 2048 ) !== $url ) { throw new AiSuggestionException( 'affilun_validation_failed', 'A review URL exceeds the Cloud limit or is invalid.', 422 ); } } $payload['review_urls'] = array_values( $payload['review_urls'] ); } return $payload; } /** * @param string $value UTF-8 text. * @param int $limit Character limit. */ private static function boundedText( string $value, int $limit ): string { if ( function_exists( 'mb_substr' ) ) { return mb_substr( $value, 0, $limit, 'UTF-8' ); } if ( preg_match( '/\A.{0,' . $limit . '}/us', $value, $matches ) !== 1 ) { throw new AiSuggestionException( 'affilun_validation_failed', 'Product text could not be normalized.', 422 ); } return $matches[0]; } /** * Identify the actual generator, including responses from older APIs. * * @param array $draft Draft response. * @return array{source:string,model:string,provider:string} */ public static function provenance( array $draft ): array { $usage = is_array( $draft['usage'] ?? null ) ? $draft['usage'] : array(); $provider = is_string( $usage['provider'] ?? null ) ? $usage['provider'] : ''; $model = is_string( $draft['model'] ?? null ) ? $draft['model'] : ''; $source = 'affilun_cloud'; if ( $provider === 'plugin_fallback' || str_starts_with( $model, 'plugin-fallback' ) ) { $source = 'plugin_fallback'; } elseif ( ! empty( $draft['provider_unavailable'] ) || ! empty( $draft['safety_blocked'] ) || str_contains( $provider, 'fallback' ) || str_contains( $model, 'fallback' ) ) { $source = 'cloud_fallback'; } elseif ( $provider === 'heuristic' || str_starts_with( $model, 'heuristic-' ) ) { $source = 'cloud_heuristic'; } return array( 'source' => $source, 'model' => $model, 'provider' => $provider, ); } /** * Build a local fallback article draft when Cloud generation is unavailable. * * @param array $payload Request payload. * * @return array */ private static function fallbackDraft( array $payload ): array { $title = is_scalar( $payload['title'] ?? null ) ? (string) $payload['title'] : '商品'; $brand = is_scalar( $payload['brand'] ?? null ) ? trim( (string) $payload['brand'] ) : ''; $description = is_scalar( $payload['description'] ?? null ) ? trim( (string) $payload['description'] ) : ''; $tone = is_scalar( $payload['tone'] ?? null ) ? (string) $payload['tone'] : 'professional_blog'; $length = is_scalar( $payload['target_length'] ?? null ) ? (string) $payload['target_length'] : 'medium'; if ( ! array_key_exists( $tone, self::ARTICLE_DRAFT_TONE_LEADS ) ) { $tone = 'professional_blog'; } if ( ! array_key_exists( $length, self::ARTICLE_DRAFT_LENGTH_CHARS ) ) { $length = 'medium'; } $points = array_filter( array( $brand !== '' ? 'ブランド: ' . $brand : '', $description !== '' ? $description : '', '最新の価格・在庫・配送条件はリンク先で確認してください。', ), static fn ( string $value ): bool => $value !== '' ); $body = array( '# ' . $title . ' の紹介', '', self::ARTICLE_DRAFT_TONE_LEADS[ $tone ], '', '## 推しポイント', ); foreach ( $points as $point ) { $body[] = '- ' . $point; } $body[] = ''; $body[] = '## まとめ'; $body[] = $title . ' が気になる場合は、商品リンク先で最新情報を確認してから検討してください。'; return array( 'title' => $title . ' の紹介', 'excerpt' => $title . ' の特徴と購入前のチェックポイントをまとめました。', 'body_markdown' => implode( "\n", $body ), 'sources' => array(), 'model' => 'plugin-fallback-article-draft', 'usage' => array( 'units' => 0, 'provider' => 'plugin_fallback', 'tone' => $tone, 'target_length' => $length, 'length_chars' => self::ARTICLE_DRAFT_LENGTH_CHARS[ $length ], ), ); } /** * JSON encode request payload. * * @param array $payload Payload. */ private static function jsonEncode( array $payload ): string { if ( function_exists( 'wp_json_encode' ) ) { $body = wp_json_encode( $payload ); } else { // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Pure-unit fallback when WordPress is unavailable. $body = json_encode( $payload ); } if ( ! is_string( $body ) ) { throw new RuntimeException( 'Failed to encode affilun Cloud request.' ); } return $body; } /** * Default WordPress HTTP transport. * * @return callable * * @phpstan-return callable(string,array,string):array */ private static function defaultTransport(): callable { return static function ( string $url, array $headers, string $body ): array { $response = CloudHttpOptions::safePost( $url, array( 'timeout' => 15, 'headers' => $headers, 'body' => $body, ) ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $response ) ) { return array( 'status' => 503, 'data' => null, ); } $raw = is_array( $response ) ? (string) wp_remote_retrieve_body( $response ) : ''; $decoded = json_decode( $raw, true ); return array( 'status' => is_array( $response ) ? (int) wp_remote_retrieve_response_code( $response ) : 0, 'data' => is_array( $decoded ) ? $decoded : null, ); }; } }