currency = get_woocommerce_currency(); $this->store_name = get_bloginfo( 'name' ); $this->store_url = home_url(); } /** * Generate Google Merchant Center XML feed (RSS 2.0 with Google Shopping namespace). * * @return string XML string. */ public function generate_google_xml() { $products = $this->get_products(); $xml = '' . "\n"; $xml .= '' . "\n"; $xml .= ' ' . "\n"; $xml .= ' ' . $this->xml_escape( $this->store_name ) . '' . "\n"; $xml .= ' ' . $this->xml_escape( $this->store_url ) . '' . "\n"; $xml .= ' ' . $this->xml_escape( get_bloginfo( 'description' ) ) . '' . "\n"; foreach ( $products as $item ) { $xml .= ' ' . "\n"; $xml .= ' ' . $this->xml_escape( (string) $item['id'] ) . '' . "\n"; $xml .= ' ' . $this->xml_escape( $item['title'] ) . '' . "\n"; $xml .= ' ' . $this->xml_escape( $item['description'] ) . '' . "\n"; $xml .= ' ' . $this->xml_escape( $item['link'] ) . '' . "\n"; $xml .= ' ' . $this->xml_escape( $item['image_link'] ) . '' . "\n"; $xml .= ' ' . $this->xml_escape( $item['availability'] ) . '' . "\n"; $xml .= ' ' . $this->xml_escape( $item['price'] . ' ' . $this->currency ) . '' . "\n"; if ( ! empty( $item['sale_price'] ) ) { $xml .= ' ' . $this->xml_escape( $item['sale_price'] . ' ' . $this->currency ) . '' . "\n"; } $xml .= ' new' . "\n"; if ( ! empty( $item['brand'] ) ) { $xml .= ' ' . $this->xml_escape( $item['brand'] ) . '' . "\n"; } if ( ! empty( $item['sku'] ) ) { $xml .= ' ' . $this->xml_escape( $item['sku'] ) . '' . "\n"; } if ( ! empty( $item['category'] ) ) { $xml .= ' ' . $this->xml_escape( $item['category'] ) . '' . "\n"; } if ( ! empty( $item['item_group_id'] ) ) { $xml .= ' ' . $this->xml_escape( (string) $item['item_group_id'] ) . '' . "\n"; } $xml .= ' ' . "\n"; } $xml .= ' ' . "\n"; $xml .= ''; return $xml; } /** * Generate Facebook Catalog XML feed (RSS 2.0 without Google namespace). * * @return string XML string. */ public function generate_facebook_xml() { $products = $this->get_products(); $xml = '' . "\n"; $xml .= '' . "\n"; $xml .= ' ' . "\n"; $xml .= ' ' . $this->xml_escape( $this->store_name ) . '' . "\n"; $xml .= ' ' . $this->xml_escape( $this->store_url ) . '' . "\n"; $xml .= ' ' . $this->xml_escape( get_bloginfo( 'description' ) ) . '' . "\n"; foreach ( $products as $item ) { $xml .= ' ' . "\n"; $xml .= ' ' . $this->xml_escape( (string) $item['id'] ) . '' . "\n"; $xml .= ' ' . $this->xml_escape( $item['title'] ) . '' . "\n"; $xml .= ' ' . $this->xml_escape( $item['description'] ) . '' . "\n"; $xml .= ' ' . $this->xml_escape( $item['link'] ) . '' . "\n"; $xml .= ' ' . $this->xml_escape( $item['image_link'] ) . '' . "\n"; $xml .= ' ' . $this->xml_escape( $item['availability'] ) . '' . "\n"; $xml .= ' ' . $this->xml_escape( $item['price'] . ' ' . $this->currency ) . '' . "\n"; if ( ! empty( $item['sale_price'] ) ) { $xml .= ' ' . $this->xml_escape( $item['sale_price'] . ' ' . $this->currency ) . '' . "\n"; } $xml .= ' new' . "\n"; if ( ! empty( $item['brand'] ) ) { $xml .= ' ' . $this->xml_escape( $item['brand'] ) . '' . "\n"; } if ( ! empty( $item['sku'] ) ) { $xml .= ' ' . $this->xml_escape( $item['sku'] ) . '' . "\n"; } if ( ! empty( $item['category'] ) ) { $xml .= ' ' . $this->xml_escape( $item['category'] ) . '' . "\n"; } if ( ! empty( $item['item_group_id'] ) ) { $xml .= ' ' . $this->xml_escape( (string) $item['item_group_id'] ) . '' . "\n"; } $xml .= ' ' . "\n"; } $xml .= ' ' . "\n"; $xml .= ''; return $xml; } /** * Generate Facebook Catalog JSON feed. * * @return string JSON string. */ public function generate_facebook_json() { $products = $this->get_products(); $data = array(); foreach ( $products as $item ) { $entry = array( 'id' => (string) $item['id'], 'title' => $item['title'], 'description' => $item['description'], 'availability' => $item['availability'], 'condition' => 'new', 'price' => $item['price'] . ' ' . $this->currency, 'link' => $item['link'], 'image_link' => $item['image_link'], ); if ( ! empty( $item['sale_price'] ) ) { $entry['sale_price'] = $item['sale_price'] . ' ' . $this->currency; } if ( ! empty( $item['brand'] ) ) { $entry['brand'] = $item['brand']; } if ( ! empty( $item['sku'] ) ) { $entry['mpn'] = $item['sku']; } if ( ! empty( $item['category'] ) ) { $entry['product_type'] = $item['category']; } if ( ! empty( $item['item_group_id'] ) ) { $entry['item_group_id'] = (string) $item['item_group_id']; } $data[] = $entry; } return wp_json_encode( array( 'data' => $data ), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE ); } /** * Get all published products (simple products + individual variations). * * @return array */ private function get_products() { $items = array(); $product_ids = get_posts( array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', ) ); foreach ( $product_ids as $pid ) { $product = wc_get_product( $pid ); if ( ! $product || ! $product->is_visible() ) { continue; } if ( $product->is_type( 'variable' ) ) { // Output each visible variation as a separate feed item. foreach ( $product->get_children() as $vid ) { $variation = wc_get_product( $vid ); if ( ! $variation || ! $variation->variation_is_visible() ) { continue; } $items[] = $this->get_variation_data( $variation, $product ); } } else { $items[] = $this->get_product_data( $product ); } } return $items; } /** * Build feed item data for a simple / external / grouped product. * * @param WC_Product $product Product object. * @return array */ private function get_product_data( $product ) { return array( 'id' => $product->get_id(), 'title' => $product->get_name(), 'description' => $this->get_description( $product ), 'link' => $this->make_absolute_url( get_permalink( $product->get_id() ) ?: home_url() ), 'image_link' => $this->get_image_url( $product ), 'availability' => $product->is_in_stock() ? 'in stock' : 'out of stock', 'price' => $this->format_price( wc_get_price_to_display( $product ) ), 'sale_price' => ( $product->is_on_sale() && '' !== $product->get_sale_price() ) ? $this->format_price( wc_get_price_to_display( $product, array( 'price' => $product->get_sale_price() ) ) ) : '', 'sku' => $product->get_sku() ?: (string) $product->get_id(), 'brand' => $this->get_brand( $product->get_id() ), 'category' => $this->get_category( $product->get_id() ), 'item_group_id' => '', ); } /** * Build feed item data for a product variation. * * @param WC_Product_Variation $variation Variation object. * @param WC_Product_Variable $parent Parent variable product. * @return array */ private function get_variation_data( $variation, $parent ) { // Build title: parent name + variation attribute values. $title = $parent->get_name(); $attr_parts = array(); foreach ( $variation->get_variation_attributes() as $attr_value ) { if ( ! empty( $attr_value ) ) { $attr_parts[] = $attr_value; } } if ( ! empty( $attr_parts ) ) { $title .= ' - ' . implode( ', ', $attr_parts ); } // Use variation image if available, fall back to parent image. $image_url = $this->get_image_url( $variation ); $placeholder_url = $this->make_absolute_url( wc_placeholder_img_src() ); if ( empty( $image_url ) || $image_url === $placeholder_url ) { $image_url = $this->get_image_url( $parent ); } return array( 'id' => $variation->get_id(), 'title' => $title, 'description' => $this->get_description( $variation, $parent ), 'link' => $this->make_absolute_url( get_permalink( $parent->get_id() ) ?: home_url() ), 'image_link' => $image_url, 'availability' => $variation->is_in_stock() ? 'in stock' : 'out of stock', 'price' => $this->format_price( wc_get_price_to_display( $variation ) ), 'sale_price' => ( $variation->is_on_sale() && '' !== $variation->get_sale_price() ) ? $this->format_price( wc_get_price_to_display( $variation, array( 'price' => $variation->get_sale_price() ) ) ) : '', 'sku' => $variation->get_sku() ?: $parent->get_sku() ?: (string) $variation->get_id(), 'brand' => $this->get_brand( $parent->get_id() ), 'category' => $this->get_category( $parent->get_id() ), 'item_group_id' => $parent->get_id(), ); } /** * Get clean product description, falling back through description → short description → parent. * * @param WC_Product $product Product object. * @param WC_Product|null $parent Parent product (for variations). * @return string */ private function get_description( $product, $parent = null ) { $desc = $product->get_description(); if ( empty( $desc ) && $parent ) { $desc = $parent->get_description(); } if ( empty( $desc ) ) { $desc = $product->get_short_description(); } if ( empty( $desc ) && $parent ) { $desc = $parent->get_short_description(); } if ( empty( $desc ) ) { $desc = $parent ? $parent->get_name() : $product->get_name(); } return wp_strip_all_tags( $desc ); } /** * Get product image URL, falling back to WooCommerce placeholder. * * @param WC_Product $product Product object. * @return string */ private function get_image_url( $product ) { $image_id = $product->get_image_id(); if ( $image_id ) { $url = wp_get_attachment_url( $image_id ); if ( $url ) { return $this->make_absolute_url( $url ); } } return $this->make_absolute_url( wc_placeholder_img_src() ); } /** * Ensure a URL is absolute and that its path segments are percent-encoded. * Handles relative paths and non-ASCII characters (e.g. Hebrew filenames). * * @param string $url Raw URL (may be relative or absolute, encoded or not). * @return string Always an absolute, properly encoded URL. */ private function make_absolute_url( $url ) { if ( empty( $url ) ) { return ''; } if ( strpos( $url, 'http://' ) !== 0 && strpos( $url, 'https://' ) !== 0 ) { $url = set_url_scheme( home_url( '/' . ltrim( $url, '/' ) ) ); } return $this->encode_url_path( $url ); } /** * Percent-encode non-ASCII characters in URL path segments, preserving slashes. * Decodes each segment before re-encoding to avoid double-encoding already-encoded URLs. * * @param string $url Absolute URL. * @return string URL with encoded path segments. */ private function encode_url_path( $url ) { $parts = parse_url( $url ); if ( empty( $parts ) || empty( $parts['path'] ) ) { return $url; } $encoded_path = implode( '/', array_map( function( $segment ) { return rawurlencode( rawurldecode( $segment ) ); }, explode( '/', $parts['path'] ) ) ); $result = ( isset( $parts['scheme'] ) ? $parts['scheme'] . '://' : '' ); $result .= ( isset( $parts['host'] ) ? $parts['host'] : '' ); $result .= ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ); $result .= $encoded_path; $result .= ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' ); $result .= ( isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : '' ); return $result; } /** * Get primary product category as a string. * * @param int $product_id Product ID. * @return string */ private function get_category( $product_id ) { $terms = wp_get_post_terms( $product_id, 'product_cat', array( 'fields' => 'names' ) ); if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) { return implode( ' > ', $terms ); } return __( 'Uncategorized', 'woocommerce' ); } /** * Get product brand from common brand taxonomies, falling back to store name. * * @param int $product_id Product ID. * @return string */ private function get_brand( $product_id ) { foreach ( array( 'product_brand', 'pa_brand', 'brand' ) as $taxonomy ) { if ( taxonomy_exists( $taxonomy ) ) { $terms = wp_get_post_terms( $product_id, $taxonomy, array( 'fields' => 'names' ) ); if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) { return $terms[0]; } } } return $this->store_name; } /** * Format a price to 2 decimal places with a period separator (no currency symbol). * * @param float $price Price value. * @return string */ private function format_price( $price ) { return number_format( (float) $price, 2, '.', '' ); } /** * Escape a value for safe inclusion in XML. * Decodes HTML entities first to prevent double-encoding (e.g. & → &). * Strips control characters that are invalid in XML 1.0 (e.g. values 0-8, 11, 12, 14-31) * which can appear in content pasted from Word, Excel, or other external sources. * * @param string $value Raw string. * @return string */ private function xml_escape( $value ) { $decoded = html_entity_decode( (string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $cleaned = preg_replace( '/[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]/u', '', $decoded ); return htmlspecialchars( $cleaned ?? $decoded, ENT_XML1 | ENT_COMPAT, 'UTF-8' ); } }