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' );
}
}