, string): array)|null $transport */ public function __construct( private CloudSettings $settings, private CloudRequestSigner $signer = new CloudRequestSigner(), ?callable $transport = null ) { $this->transport = $transport ?? self::defaultTransport(); } /** * Suggest article-context product keywords and copy. * * @param string $title Article title. * @param string $content Article content. * * @throws AiSuggestionException When Cloud suggestion fails. * @throws \Exception When nonce generation fails. */ public function suggest( string $title, string $content ): ArticleSuggestionResult { if ( ! $this->settings->isConfigured() ) { throw new AiSuggestionException( 'affilun_cloud_not_configured', 'affilun Cloud is not configured.', 400 ); } $path = '/ai/extract-keywords'; $payload = array( 'title' => self::truncate( trim( $title ), 300 ), 'content' => self::truncate( trim( self::stripTags( $content ) ), 6000 ), 'locale' => 'ja-JP', ); $body = wp_json_encode( $payload ); if ( ! is_string( $body ) ) { throw new AiSuggestionException( 'affilun_cloud_invalid_request', 'affilun Cloud request could not be encoded.', 400 ); } $headers = $this->signer->sign( $this->settings->siteId(), $this->settings->apiKey(), 'POST', $path, $body, time(), bin2hex( random_bytes( 12 ) ) ); $headers['Content-Type'] = 'application/json'; $response = ( $this->transport )( $this->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 ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Status is not output; it selects a typed exception. throw AiSuggestionException::fromCloudResponse( $status, $data ); } if ( $data === null || ! is_string( $data['search_keywords'] ?? null ) || trim( $data['search_keywords'] ) === '' ) { throw new AiSuggestionException( 'affilun_cloud_invalid_response', 'affilun Cloud response did not include search keywords.', 503, array(), true ); } return ArticleSuggestionResult::fromArray( $data, 'cloud' ); } /** * 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, ); }; } /** * Strip HTML for Cloud request minimization. * * @param string $value Raw value. */ private static function stripTags( string $value ): string { if ( function_exists( 'wp_strip_all_tags' ) ) { return (string) wp_strip_all_tags( $value ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- Pure-unit fallback when WordPress is unavailable. return strip_tags( $value ); } /** * Truncate value. * * @param string $value Value. * @param int $maxLength Max length. */ private static function truncate( string $value, int $maxLength ): string { if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) { return mb_strlen( $value ) > $maxLength ? mb_substr( $value, 0, $maxLength ) : $value; } return strlen( $value ) > $maxLength ? substr( $value, 0, $maxLength ) : $value; } }