filter_valid_products( $products ); $product_entries = array(); foreach ( $valid_products as $product ) { $entry = $this->map_product( $product ); /** * Filters the OpenAI feed product data before JSON generation. * * @param array $entry The mapped product data for the feed. * @param array $product The raw normalized product data. */ $entry = apply_filters( 'aicommerce/feed/openai/product_data', $entry, $product ); $product_entries[] = $entry; } $data = array( 'version' => '1.0', 'store' => array( 'name' => get_bloginfo( 'name' ), 'url' => home_url(), 'currency' => get_woocommerce_currency(), 'locale' => get_locale(), ), 'products' => $product_entries, 'generated_at' => gmdate( 'Y-m-d\TH:i:s\Z' ), 'total_products' => count( $product_entries ), ); return wp_json_encode( $data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ); } /** * Validate generated feed content. * * Checks JSON validity, required top-level keys, and that at least * one product has the required fields. * * @param string $content The JSON feed content. * * @return array{valid: bool, errors: string[], warnings: string[], product_count: int} */ public function validate( string $content ): array { $errors = array(); $warnings = array(); $count = 0; $data = json_decode( $content, true ); if ( null === $data ) { $errors[] = 'Invalid JSON: ' . json_last_error_msg(); return array( 'valid' => false, 'errors' => $errors, 'warnings' => $warnings, 'product_count' => 0, ); } // Required top-level keys. $required_keys = array( 'version', 'store', 'products', 'generated_at', 'total_products' ); foreach ( $required_keys as $key ) { if ( ! array_key_exists( $key, $data ) ) { $errors[] = sprintf( 'Missing required top-level key: %s.', $key ); } } // Validate products array. if ( isset( $data['products'] ) && is_array( $data['products'] ) ) { $count = count( $data['products'] ); $product_required_fields = array( 'id', 'title', 'description', 'price', 'url' ); foreach ( $data['products'] as $index => $product ) { foreach ( $product_required_fields as $field ) { if ( ! isset( $product[ $field ] ) ) { $errors[] = sprintf( 'Product %d: missing required field "%s".', $index + 1, $field ); } } // Price must be a float, not a string. if ( isset( $product['price'] ) && ! is_float( $product['price'] ) && ! is_int( $product['price'] ) ) { $errors[] = sprintf( 'Product %d: "price" must be a number, got %s.', $index + 1, gettype( $product['price'] ) ); } } } return array( 'valid' => empty( $errors ), 'errors' => $errors, 'warnings' => $warnings, 'product_count' => $count, ); } /** * Map a normalized product to the OpenAI feed structure. * * @param array $product Normalized product data. * * @return array Mapped product for the feed. */ private function map_product( array $product ): array { $entry = array( 'id' => (string) $product['id'], 'title' => $product['title'], 'description' => wp_strip_all_tags( $product['description'] ), 'price' => (float) $product['price'], 'currency' => $product['currency'] ?? 'USD', 'availability' => $product['availability'] ?? 'out_of_stock', 'url' => $product['url'], 'image_url' => $this->get_primary_image_url( $product['images'] ?? array() ), 'brand' => $product['brand'] ?? '', 'category' => $this->get_top_category( $product['categories'] ?? array() ), ); // GTIN — include only if present. $entry['gtin'] = ! empty( $product['gtin'] ) ? $product['gtin'] : null; // Rating — omit if no reviews. $rating_value = (float) ( $product['rating_value'] ?? 0 ); $rating_count = (int) ( $product['rating_count'] ?? 0 ); if ( $rating_count > 0 && $rating_value > 0 ) { $entry['rating'] = array( 'value' => $rating_value, 'count' => $rating_count, ); } // Variants for variable products. if ( ! empty( $product['variants'] ) ) { $entry['variants'] = $this->map_variants( $product['variants'] ); } return $entry; } /** * Get primary image URL from images array. * * @param array $images Image data (url/alt arrays or plain strings). * * @return string Primary image URL or empty string. */ private function get_primary_image_url( array $images ): string { if ( empty( $images ) ) { return ''; } $first = $images[0]; if ( is_array( $first ) && isset( $first['url'] ) ) { return $first['url']; } return is_string( $first ) ? $first : ''; } /** * Get the top-level category name. * * @param array $categories Category data. * * @return string Category name or empty string. */ private function get_top_category( array $categories ): string { if ( empty( $categories ) ) { return ''; } // Find root category (parent_id = 0) or use first. foreach ( $categories as $cat ) { if ( isset( $cat['parent_id'] ) && 0 === (int) $cat['parent_id'] ) { return $cat['name'] ?? ''; } } return $categories[0]['name'] ?? ''; } /** * Map variants for the feed. * * @param array $variants Variant data from extractor. * * @return array Mapped variants. */ private function map_variants( array $variants ): array { $mapped = array(); foreach ( $variants as $variant ) { $attrs = $variant['attributes'] ?? array(); $attr_values = array(); foreach ( $attrs as $value ) { if ( is_string( $value ) && '' !== $value ) { $attr_values[] = $value; } } $mapped[] = array( 'id' => (string) ( $variant['id'] ?? '' ), 'title' => ! empty( $attr_values ) ? implode( ' / ', $attr_values ) : '', 'price' => (float) ( $variant['price'] ?? 0 ), 'availability' => $variant['availability'] ?? 'out_of_stock', 'attributes' => $attrs, ); } return $mapped; } }