stats;
}
private function reset_stats(): void {
$this->stats = [
'candidates' => 0,
'included' => 0,
'skipped' => 0,
'reasons' => [],
];
}
private function mark_candidate(): void {
$this->stats['candidates']++;
}
private function mark_included(): void {
$this->stats['included']++;
}
private function mark_skipped( string $reason ): void {
$this->stats['skipped']++;
if ( ! isset( $this->stats['reasons'][ $reason ] ) ) {
$this->stats['reasons'][ $reason ] = 0;
}
$this->stats['reasons'][ $reason ]++;
}
private function clean_xml_text( string $text ): string {
$text = str_replace( [ ' ', "\xC2\xA0" ], ' ', $text );
$text = html_entity_decode( $text, ENT_QUOTES | ENT_XML1, 'UTF-8' );
return $text;
}
private function clean_title( string $title ): string {
$title = $this->clean_xml_text( wp_strip_all_tags( $title, true ) );
$title = (string) preg_replace( '/\s+/u', ' ', $title );
return limit_text( trim( $title ), 150 );
}
public function output(): void {
$this->reset_stats();
add_filter( 'doing_it_wrong_trigger_error', '__return_false' );
header( 'Content-Type: application/xml; charset=utf-8' );
echo '' . "\n";
echo '' . "\n";
echo "\n";
echo '' . esc_html( get_bloginfo( 'name' ) ) . ' – Google Merchant Feed' . "\n";
echo '' . esc_url( home_url( '/' ) ) . "\n";
$description = trim( (string) get_option( 'tdl_gmc_feed_channel_description', '' ) );
if ( $description === '' ) $description = 'WooCommerce product feed';
echo '' . esc_html( $description ) . "\n";
$this->loop_products();
echo "\n";
remove_filter( 'doing_it_wrong_trigger_error', '__return_false' );
}
private function loop_products(): void {
$paged = 1;
do {
$query = new \WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 500,
'paged' => $paged,
'no_found_rows' => true,
'fields' => 'ids',
] );
if ( ! $query->have_posts() ) break;
foreach ( $query->posts as $product_id ) {
$product = wc_get_product( $product_id );
if ( ! $product instanceof WC_Product ) continue;
$this->emit_product( $product );
}
$paged++;
wp_reset_postdata();
} while ( true );
}
private function emit_product( WC_Product $product ): void {
if ( $product instanceof WC_Product_Variable || $product->is_type( 'variable' ) ) {
foreach ( $product->get_children() as $variation_id ) {
$this->mark_candidate();
$variation = wc_get_product( $variation_id );
if ( ! $variation instanceof WC_Product_Variation ) {
$this->mark_skipped( 'invalid_variation' );
continue;
}
if ( method_exists( $variation, 'get_status' ) && $variation->get_status() !== 'publish' ) {
$this->mark_skipped( 'not_published' );
continue;
}
if ( ! $variation->is_purchasable() ) {
$this->mark_skipped( 'not_purchasable' );
continue;
}
$this->emit_item( $product, $variation );
}
return;
}
$this->mark_candidate();
if ( ! $product->is_purchasable() ) {
$this->mark_skipped( 'not_purchasable' );
return;
}
$this->emit_item( $product, null );
}
private function emit_item( WC_Product $product, ?WC_Product_Variation $variation ): void {
if ( function_exists( 'TDL_GMC_Lite\\tdl_gmc_filter_result' ) ) {
$filter = tdl_gmc_filter_result( $product, $variation );
if ( empty( $filter['pass'] ) ) {
$this->mark_skipped( ! empty( $filter['reason'] ) ? (string) $filter['reason'] : 'custom_filter' );
return;
}
} elseif ( function_exists( 'TDL_GMC_Lite\\tdl_gmc_passes_filters' ) && ! tdl_gmc_passes_filters( $product, $variation ) ) {
$this->mark_skipped( 'custom_filter' );
return;
}
$subject = $variation ?: $product;
$title = $this->resolve_title( $product, $variation );
$description = $this->compose_description( $product, $variation );
$price = $this->price_str( $subject );
if ( $title === '' ) {
$this->mark_skipped( 'missing_title' );
return;
}
if ( $price === '' ) {
$this->mark_skipped( 'missing_price' );
return;
}
$image = $this->image_url( $product, $variation );
// Google Merchant requires a main image for every product. Do not emit an item
// that Google would reject immediately.
if ( $image === '' ) {
$this->mark_skipped( 'missing_image' );
return;
}
[ $identifier_exists, $gtin, $mpn, $brand ] = identifier_block( $product, $variation );
[ $unit_measure, $unit_base ] = derive_unit_measures( $product, $variation );
echo " - \n";
echo ' ' . esc_html( $this->compose_item_id( $product, $variation ) ) . "\n";
echo ' ' . esc_html( $title ) . "\n";
echo ' ' . esc_html( $description ) . "\n";
echo ' ' . esc_url( product_variant_url( $product, $variation ) ) . "\n";
echo ' ' . esc_url( $image ) . "\n";
foreach ( $this->additional_images( $product, $variation ) as $img ) {
echo ' ' . esc_url( $img ) . "\n";
}
echo ' ' . esc_html( $this->availability( $product, $variation ) ) . "\n";
echo " new\n";
echo ' ' . esc_html( $price ) . "\n";
if ( $brand !== '' ) echo ' ' . esc_html( limit_text( $brand, 70 ) ) . "\n";
if ( $gtin !== '' ) echo ' ' . esc_html( $gtin ) . "\n";
if ( $mpn !== '' ) echo ' ' . esc_html( $mpn ) . "\n";
echo ' ' . esc_html( $identifier_exists ) . "\n";
if ( $variation instanceof WC_Product_Variation ) {
$group_id = $product->get_sku() ?: 'GID-' . $product->get_id();
echo ' ' . esc_html( $group_id ) . "\n";
}
$product_type = $this->product_breadcrumb( $product );
if ( $product_type !== '' ) echo ' ' . esc_html( $product_type ) . "\n";
$gpc = map_google_category( $product );
if ( $gpc !== '' ) echo ' ' . esc_html( $gpc ) . "\n";
if ( $unit_measure !== '' ) echo ' ' . esc_html( $unit_measure ) . "\n";
if ( $unit_base !== '' ) echo ' ' . esc_html( $unit_base ) . "\n";
echo "
\n";
$this->mark_included();
}
private function resolve_title( WC_Product $product, ?WC_Product_Variation $variation ): string {
if ( $variation instanceof WC_Product_Variation ) {
$manual_variation = trim( (string) get_post_meta( $variation->get_id(), '_google_title_variation', true ) );
if ( $manual_variation !== '' ) return $this->clean_title( $manual_variation );
}
$manual_product = trim( (string) get_post_meta( $product->get_id(), '_google_title', true ) );
if ( $manual_product !== '' ) {
$title = $this->clean_title( $manual_product );
return $variation ? $this->title_with_variation_attributes( $title, $variation ) : $title;
}
$title = $this->clean_title( (string) ( $variation ?: $product )->get_name() );
return $variation ? $this->title_with_variation_attributes( $title, $variation ) : $title;
}
private function title_with_variation_attributes( string $title, WC_Product_Variation $variation ): string {
$attrs = method_exists( $variation, 'get_variation_attributes' ) ? $variation->get_variation_attributes() : $variation->get_attributes();
if ( ! is_array( $attrs ) || ! $attrs ) return $title;
$missing = [];
$title_norm = strtolower( remove_accents( $title ) );
foreach ( $attrs as $key => $value ) {
if ( $value === '' || $value === null ) continue;
$taxonomy = (string) preg_replace( '/^attribute_/', '', (string) $key );
$label = (string) $value;
if ( taxonomy_exists( $taxonomy ) ) {
$term = get_term_by( 'slug', (string) $value, $taxonomy );
if ( $term && ! is_wp_error( $term ) && isset( $term->name ) ) $label = (string) $term->name;
}
$label = $this->clean_title( rawurldecode( $label ) );
if ( $label === '' ) continue;
$label_norm = strtolower( remove_accents( $label ) );
if ( strpos( $title_norm, $label_norm ) === false ) $missing[] = $label;
}
$missing = array_values( array_unique( $missing ) );
if ( ! $missing ) return $title;
return limit_text( $title . ' - ' . implode( ' / ', $missing ), 150 );
}
private function compose_item_id( WC_Product $product, ?WC_Product_Variation $variation ): string {
$sku = trim( (string) ( $variation ?: $product )->get_sku() );
if ( $sku !== '' ) return $sku;
return $variation ? $product->get_id() . '-' . $variation->get_id() : (string) $product->get_id();
}
private function compose_description( WC_Product $product, ?WC_Product_Variation $variation ): string {
$candidates = [];
if ( $variation instanceof WC_Product_Variation ) $candidates[] = (string) $variation->get_description();
$candidates[] = (string) $product->get_short_description();
$candidates[] = (string) $product->get_description();
$candidates[] = (string) $product->get_name();
foreach ( $candidates as $desc ) {
if ( trim( $desc ) === '' ) continue;
$desc = $this->clean_xml_text( $desc );
$desc = (string) preg_replace( '/<\s*(br|\/p|\/div|\/li|\/h[1-6])\b[^>]*>/iu', ' ', $desc );
$desc = wp_strip_all_tags( $desc );
$desc = trim( (string) preg_replace( '/\s+/u', ' ', $desc ) );
if ( $desc !== '' ) return limit_text( $desc, 5000 );
}
return '';
}
private function image_url( WC_Product $product, ?WC_Product_Variation $variation ): string {
$image_ids = [];
if ( $variation instanceof WC_Product_Variation && $variation->get_image_id() ) {
$image_ids[] = (int) $variation->get_image_id();
}
if ( $product->get_image_id() ) {
$image_ids[] = (int) $product->get_image_id();
}
foreach ( (array) $product->get_gallery_image_ids() as $gallery_id ) {
$image_ids[] = (int) $gallery_id;
}
$url = '';
foreach ( array_values( array_unique( array_filter( $image_ids ) ) ) as $image_id ) {
$src = wp_get_attachment_image_src( $image_id, 'full' );
if ( is_array( $src ) && ! empty( $src[0] ) ) {
$url = (string) $src[0];
break;
}
}
return trim( (string) apply_filters( 'tdl_gmc_image_url', $url, $product, $variation ) );
}
private function additional_images( WC_Product $product, ?WC_Product_Variation $variation ): array {
$urls = [];
$main = $this->image_url( $product, $variation );
foreach ( (array) $product->get_gallery_image_ids() as $image_id ) {
$src = wp_get_attachment_image_src( $image_id, 'full' );
if ( is_array( $src ) && ! empty( $src[0] ) && $src[0] !== $main ) $urls[] = (string) $src[0];
if ( count( $urls ) >= 10 ) break;
}
return array_values( array_unique( $urls ) );
}
private function price_str( WC_Product $product ): string {
$price = wc_get_price_including_tax( $product );
if ( ! is_numeric( $price ) ) $price = $product->get_price();
if ( ! is_numeric( $price ) ) return '';
return wc_format_decimal( $price, wc_get_price_decimals() ) . ' ' . get_woocommerce_currency();
}
private function availability( WC_Product $product, ?WC_Product_Variation $variation ): string {
$subject = $variation ?: $product;
if ( method_exists( $subject, 'is_on_backorder' ) && $subject->is_on_backorder( 1 ) ) return 'backorder';
if ( $subject->is_in_stock() ) return 'in_stock';
return 'out_of_stock';
}
private function product_breadcrumb( WC_Product $product ): string {
$terms = get_the_terms( $product->get_id(), 'product_cat' );
if ( ! is_array( $terms ) || ! $terms ) return '';
$deepest = null;
$max_depth = -1;
foreach ( $terms as $term ) {
$depth = count( (array) get_ancestors( $term->term_id, 'product_cat' ) );
if ( $depth > $max_depth ) {
$max_depth = $depth;
$deepest = $term;
}
}
if ( ! $deepest ) return '';
$ids = array_reverse( (array) get_ancestors( $deepest->term_id, 'product_cat' ) );
$ids[] = $deepest->term_id;
$names = [];
foreach ( $ids as $id ) {
$term = get_term( $id, 'product_cat' );
if ( $term && ! is_wp_error( $term ) && isset( $term->name ) ) $names[] = (string) $term->name;
}
return implode( ' > ', $names );
}
}