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