filter_valid_products( $products ); $items = array(); foreach ( $valid_products as $product ) { $items[] = $this->build_product_item( $product ); } $feed = array( 'version' => '1.0', 'format' => 'perplexity-product-feed', 'store' => array( 'name' => get_bloginfo( 'name' ), 'url' => home_url(), 'currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : 'USD', 'locale' => get_locale(), ), 'products' => $items, 'total_products' => count( $items ), 'generated_at' => gmdate( 'c' ), ); return wp_json_encode( $feed, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); } /** * Generate TSV format of the feed. * * @param array $products Array of normalized product data. * * @return string The generated TSV feed content. */ public function generate_tsv( array $products ): string { $valid_products = $this->filter_valid_products( $products ); $headers = array( 'id', 'title', 'description', 'url', 'image_url', 'price', 'currency', 'availability', 'brand', 'category', 'gtin', 'features', 'use_cases', ); $lines = array(); $lines[] = implode( "\t", $headers ); foreach ( $valid_products as $product ) { $description = wp_strip_all_tags( $product['description'] ?? '' ); $images = $this->normalize_images( $product['images'] ?? array() ); $features = $this->extract_features( $product ); $use_cases = $this->extract_use_cases( $product ); $row = array( (string) $product['id'], $this->sanitize_tsv( $product['title'] ), $this->sanitize_tsv( mb_substr( $description, 0, 5000 ) ), $product['url'], $images[0] ?? '', number_format( (float) $product['price'], 2, '.', '' ), $product['currency'] ?? 'USD', $product['availability'] ?? 'out_of_stock', $product['brand'] ?? '', $this->get_top_category( $product['categories'] ?? array() ), $product['gtin'] ?? '', $this->sanitize_tsv( implode( '|', $features ) ), $this->sanitize_tsv( implode( '|', $use_cases ) ), ); $lines[] = implode( "\t", $row ); } return implode( "\n", $lines ) . "\n"; } /** * Validate generated feed content. * * @param string $content The feed content to validate. * * @return array{valid: bool, errors: string[], warnings: string[], product_count: int} */ public function validate( string $content ): array { $errors = array(); $warnings = array(); $count = 0; $decoded = json_decode( $content, true ); if ( null === $decoded ) { $errors[] = 'Invalid JSON: ' . json_last_error_msg(); return array( 'valid' => false, 'errors' => $errors, 'warnings' => $warnings, 'product_count' => 0, ); } $required_keys = array( 'version', 'store', 'products', 'total_products' ); foreach ( $required_keys as $key ) { if ( ! array_key_exists( $key, $decoded ) ) { $errors[] = sprintf( 'Missing required top-level key: %s', $key ); } } if ( isset( $decoded['products'] ) && is_array( $decoded['products'] ) ) { $count = count( $decoded['products'] ); $required_fields = array( 'id', 'title', 'url', 'price' ); foreach ( $decoded['products'] as $index => $product ) { foreach ( $required_fields as $field ) { if ( ! isset( $product[ $field ] ) || '' === $product[ $field ] ) { $errors[] = sprintf( 'Product %d: missing required field "%s".', $index + 1, $field ); } } if ( isset( $product['price'] ) && ! is_float( $product['price'] ) && ! is_int( $product['price'] ) ) { $errors[] = sprintf( 'Product %d: price must be numeric, got %s.', $index + 1, gettype( $product['price'] ) ); } } } return array( 'valid' => empty( $errors ), 'errors' => $errors, 'warnings' => $warnings, 'product_count' => $count, ); } /** * Build a single product item for the JSON feed. * * @param array $product Normalized product data. * * @return array Product item array. */ private function build_product_item( array $product ): array { $description = wp_strip_all_tags( $product['description'] ?? '' ); $images = $this->normalize_images( $product['images'] ?? array() ); $features = $this->extract_features( $product ); $use_cases = $this->extract_use_cases( $product ); $item = array( 'id' => (string) $product['id'], 'title' => $product['title'], 'description' => $description, 'url' => $product['url'], 'image_url' => $images[0] ?? null, 'price' => (float) $product['price'], 'currency' => $product['currency'] ?? 'USD', 'availability' => $product['availability'] ?? 'out_of_stock', 'brand' => $product['brand'] ?? '', 'category' => $this->get_top_category( $product['categories'] ?? array() ), 'gtin' => ! empty( $product['gtin'] ) ? $product['gtin'] : null, 'features' => $features, 'use_cases' => $use_cases, ); if ( ! empty( $product['sale_price'] ) ) { $item['sale_price'] = (float) $product['sale_price']; } if ( count( $images ) > 1 ) { $item['additional_images'] = array_slice( $images, 1, 5 ); } if ( ! empty( $product['rating_value'] ) && $product['rating_count'] > 0 ) { $item['rating'] = array( 'value' => (float) $product['rating_value'], 'count' => (int) $product['rating_count'], ); } if ( ! empty( $product['variants'] ) ) { $item['variants'] = $product['variants']; } /** * Filters the Perplexity feed item data. * * @param array $item The product item data. * @param array $product The raw normalized product data. */ $item = apply_filters( 'aicommerce/feed/perplexity/product_data', $item, $product ); return $item; } /** * Extract product features from description and attributes. * * Splits description into short bullet-point-like sentences that * describe key product features. * * @param array $product Normalized product data. * * @return array Array of feature strings. */ private function extract_features( array $product ): array { $features = array(); // Extract from short_description if available. $source = ! empty( $product['short_description'] ) ? $product['short_description'] : ( $product['description'] ?? '' ); $source = wp_strip_all_tags( $source ); // Split on sentence boundaries or list items. $sentences = preg_split( '/(?<=[.!?])\s+|[\r\n]+/', $source, -1, PREG_SPLIT_NO_EMPTY ); foreach ( $sentences as $sentence ) { $sentence = trim( $sentence, " \t\n\r\0\x0B-•*" ); if ( strlen( $sentence ) >= 10 && strlen( $sentence ) <= 200 ) { $features[] = $sentence; } if ( count( $features ) >= 5 ) { break; } } // Add key attributes as features. if ( ! empty( $product['attributes'] ) ) { foreach ( $product['attributes'] as $key => $attr ) { if ( count( $features ) >= 8 ) { break; } $value = ''; if ( is_array( $attr ) && isset( $attr['options'] ) ) { $value = implode( ', ', (array) $attr['options'] ); } elseif ( is_string( $attr ) ) { $value = $attr; } if ( ! empty( $value ) ) { $label = ucfirst( str_replace( array( 'pa_', '_' ), array( '', ' ' ), $key ) ); $features[] = $label . ': ' . $value; } } } /** * Filters the extracted features for a product. * * @param array $features Extracted features. * @param array $product The product data. */ return apply_filters( 'aicommerce/feed/perplexity/features', $features, $product ); } /** * Extract potential use cases from product data. * * Derives use cases from categories, attributes, and description. * * @param array $product Normalized product data. * * @return array Array of use case strings. */ private function extract_use_cases( array $product ): array { $use_cases = array(); // Derive from categories. if ( ! empty( $product['categories'] ) ) { foreach ( $product['categories'] as $category ) { if ( ! empty( $category['name'] ) ) { $use_cases[] = $category['name']; } if ( count( $use_cases ) >= 3 ) { break; } } } /** * Filters the extracted use cases for a product. * * @param array $use_cases Extracted use cases. * @param array $product The product data. */ return apply_filters( 'aicommerce/feed/perplexity/use_cases', $use_cases, $product ); } /** * Get the top-level category name. * * @param array $categories Array of category data. * * @return string Top-level category name or empty string. */ private function get_top_category( array $categories ): string { if ( empty( $categories ) ) { return ''; } // Find root category (parent_id = 0). foreach ( $categories as $category ) { if ( isset( $category['parent_id'] ) && 0 === (int) $category['parent_id'] ) { return $category['name'] ?? ''; } } // Fallback to first category. return $categories[0]['name'] ?? ''; } /** * Normalize images to array of URL strings. * * @param array $images Raw image data. * * @return array Array of image URLs. */ private function normalize_images( array $images ): array { $urls = array(); foreach ( $images as $image ) { if ( is_array( $image ) && isset( $image['url'] ) ) { $urls[] = $image['url']; } elseif ( is_string( $image ) ) { $urls[] = $image; } } return $urls; } /** * Sanitize a value for TSV output. * * @param string $value The value to sanitize. * * @return string Sanitized value. */ private function sanitize_tsv( string $value ): string { return str_replace( array( "\t", "\n", "\r" ), ' ', $value ); } }