query_vars[ self::QUERY_VAR ] ) ) { $which = (string) $wp->query_vars[ self::QUERY_VAR ]; } elseif ( isset( $_GET[ self::QUERY_VAR ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification $which = sanitize_key( wp_unslash( $_GET[ self::QUERY_VAR ] ) ); // phpcs:ignore WordPress.Security.NonceVerification } if ( 'google' !== $which ) { return; } if ( ! Settings::feature( 'feed_google' ) || ! Util::woo_active() ) { status_header( 404 ); nocache_headers(); header( 'Content-Type: text/plain; charset=UTF-8' ); echo 'Feed disabled'; exit; } $xml = get_transient( self::TRANSIENT ); if ( ! is_string( $xml ) || '' === $xml ) { $xml = self::build(); set_transient( self::TRANSIENT, $xml, self::TTL ); } status_header( 200 ); header( 'Content-Type: application/xml; charset=UTF-8' ); header( 'X-Robots-Tag: noindex' ); header( 'Cache-Control: public, max-age=900' ); echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput -- built with self::x(). exit; } /** Escape text for XML 1.0 (removes characters that are invalid in XML). */ public static function x( $s ): string { $s = (string) $s; if ( ! mb_check_encoding( $s, 'UTF-8' ) ) { $s = mb_convert_encoding( $s, 'UTF-8', 'UTF-8' ); } $s = (string) preg_replace( '/[^\x{9}\x{A}\x{D}\x{20}-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]/u', '', $s ); return htmlspecialchars( $s, ENT_XML1 | ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' ); } private static function money( $amount, string $currency ): string { return number_format( (float) $amount, 2, '.', '' ) . ' ' . $currency; } /** Shopper-facing price: tax-exclusive for US/CA, tax-inclusive elsewhere (Google spec). */ private static function display_price( \WC_Product $p, $raw ): string { if ( '' === (string) $raw ) { return ''; } $country = function_exists( 'WC' ) && WC()->countries ? WC()->countries->get_base_country() : 'US'; if ( in_array( $country, array( 'US', 'CA' ), true ) || ! wc_tax_enabled() ) { return (string) wc_get_price_excluding_tax( $p, array( 'price' => $raw ) ); } return (string) wc_get_price_including_tax( $p, array( 'price' => $raw ) ); } public static function availability( string $stock_status ): string { switch ( $stock_status ) { case 'instock': return 'in_stock'; case 'onbackorder': return 'backorder'; case 'preorder': case 'pre-order': return 'preorder'; default: return 'out_of_stock'; } } private static function truncate( string $s, int $max ): string { return mb_strlen( $s ) > $max ? rtrim( mb_substr( $s, 0, $max - 1 ) ) . '…' : $s; } /** Products in scope (feed_scope "treatment" = only treatment_ids). */ public static function scope_products(): array { $settings = Settings::all(); $args = array( 'status' => 'publish', 'limit' => self::MAX_ITEMS, 'orderby' => 'ID', 'order' => 'ASC', 'type' => array( 'simple', 'variable', 'external' ), ); if ( 'treatment' === $settings['feed_scope'] ) { if ( ! $settings['treatment_ids'] ) { return array(); } $args['include'] = $settings['treatment_ids']; } return (array) wc_get_products( $args ); } /** * g:availability_date (ISO 8601) for backorder / preorder items, only when the merchant has recorded one: * product (or parent) meta `_flareops_availability_date`, or the `flareops_availability_date` filter (for * pre-order plugins). Only future dates are used. Otherwise '' (Merchant Center may then warn; never guessed). */ public static function availability_date( \WC_Product $p, ?\WC_Product $parent ): string { $raw = (string) $p->get_meta( '_flareops_availability_date', true ); if ( '' === $raw && $parent ) { $raw = (string) $parent->get_meta( '_flareops_availability_date', true ); } $raw = (string) apply_filters( 'flareops_availability_date', $raw, $p, $parent ); if ( '' === trim( $raw ) ) { return ''; } try { $tz = function_exists( 'wp_timezone' ) ? wp_timezone() : new \DateTimeZone( 'UTC' ); $dt = new \DateTimeImmutable( trim( $raw ), $tz ); } catch ( \Throwable $e ) { return ''; } if ( $dt->getTimestamp() <= time() ) { return ''; } return $dt->format( 'c' ); } /** Image URLs: [main, additional...]. Variations use their own image first, then the parent's image and gallery. */ public static function images( \WC_Product $p, ?\WC_Product $parent ): array { $main = Products::image_url( $p ); $ids = array(); if ( $parent && $parent->get_image_id() ) { $ids[] = (int) $parent->get_image_id(); } $base = $parent ? $parent : $p; foreach ( (array) $base->get_gallery_image_ids() as $gid ) { $ids[] = (int) $gid; } $extra = array(); foreach ( array_unique( $ids ) as $gid ) { $u = $gid ? wp_get_attachment_image_url( $gid, 'full' ) : ''; if ( $u && $u !== $main && ! in_array( $u, $extra, true ) ) { $extra[] = (string) $u; } if ( count( $extra ) >= 10 ) { break; // Google accepts up to 10 additional_image_link values. } } return array( $main, $extra ); } /** Description: own description, parent's (variations), short description, then title. Plain text, ≤ 5000 chars. */ public static function description( \WC_Product $p, ?\WC_Product $parent, string $title ): string { foreach ( array( static fn() => $p->get_description(), static fn() => $parent ? $parent->get_description() : '', static fn() => $p->get_short_description(), static fn() => $parent ? $parent->get_short_description() : '', ) as $get ) { $d = Util::plain_text( $get() ); if ( '' !== $d ) { return self::truncate( $d, 5000 ); } } return self::truncate( $title, 5000 ); } /** One for a sellable product or variation; '' when it cannot be listed. */ private static function item( \WC_Product $p, ?\WC_Product $parent, string $currency ): string { $base = $parent ? $parent : $p; if ( 'hidden' === $base->get_catalog_visibility() || post_password_required( $base->get_id() ) ) { return ''; } $regular = $p->get_regular_price(); $active = $p->get_price(); if ( '' === (string) $regular ) { $regular = $active; } if ( '' === (string) $active || '' === (string) $regular || (float) $regular <= 0 ) { return ''; // Google requires a price; never invent one. Products without a price are left out. } $title = html_entity_decode( (string) $p->get_name(), ENT_QUOTES, 'UTF-8' ); $desc = self::description( $p, $parent, $title ); $gtin = Products::gtin( $p ); $mpn = Products::mpn( $p ); $brand = Products::effective_brand( $p ); $type = Products::category_path( $base->get_id() ); $gcat = Products::google_category( $base->get_id() ); [ $img, $extra ] = self::images( $p, $parent ); $avail = self::availability( (string) $p->get_stock_status() ); $f = array(); $f[] = '' . self::x( (string) $p->get_id() ) . ''; if ( $parent ) { $f[] = '' . self::x( (string) $parent->get_id() ) . ''; } $f[] = '' . self::x( self::truncate( $title, 150 ) ) . ''; $f[] = '' . self::x( $desc ) . ''; $f[] = '' . self::x( (string) $p->get_permalink() ) . ''; if ( '' !== $img ) { $f[] = '' . self::x( $img ) . ''; } foreach ( $extra as $u ) { $f[] = '' . self::x( $u ) . ''; } $f[] = '' . $avail . ''; if ( 'backorder' === $avail || 'preorder' === $avail ) { $ad = self::availability_date( $p, $parent ); if ( '' !== $ad ) { $f[] = '' . self::x( $ad ) . ''; } } $f[] = '' . self::x( self::money( self::display_price( $p, $regular ), $currency ) ) . ''; if ( $p->is_on_sale( 'edit' ) && '' !== (string) $p->get_sale_price() && (float) $p->get_sale_price() < (float) $regular ) { $f[] = '' . self::x( self::money( self::display_price( $p, $p->get_sale_price() ), $currency ) ) . ''; $from = $p->get_date_on_sale_from(); $to = $p->get_date_on_sale_to(); if ( $from && $to ) { $f[] = '' . self::x( $from->date( 'c' ) . '/' . $to->date( 'c' ) ) . ''; } } if ( '' !== $brand ) { $f[] = '' . self::x( $brand ) . ''; } if ( '' !== $gtin ) { $f[] = '' . self::x( $gtin ) . ''; } if ( '' !== $mpn ) { $f[] = '' . self::x( $mpn ) . ''; } if ( '' === $gtin && '' === $mpn ) { $f[] = 'no'; } $f[] = '' . self::x( Products::condition( $p ) ) . ''; if ( '' !== $gcat ) { $f[] = '' . self::x( $gcat ) . ''; } if ( '' !== $type ) { $f[] = '' . self::x( $type ) . ''; } if ( $parent ) { foreach ( Products::attributes( $p ) as $label => $vals ) { $l = strtolower( (string) $label ); if ( in_array( $l, array( 'color', 'colour' ), true ) ) { $f[] = '' . self::x( implode( '/', $vals ) ) . ''; } elseif ( 'size' === $l ) { $f[] = '' . self::x( implode( '/', $vals ) ) . ''; } } } $ship = Settings::all()['feed_shipping']; if ( is_array( $ship ) ) { $sh = '' . self::x( $ship['country'] ) . ''; if ( '' !== $ship['service'] ) { $sh .= '' . self::x( $ship['service'] ) . ''; } $sh .= '' . self::x( self::money( $ship['price'], $currency ) ) . ''; $f[] = '' . $sh . ''; } $w = $p->get_weight(); if ( '' !== (string) $w && (float) $w > 0 ) { $f[] = '' . self::x( wc_format_decimal( $w ) . ' ' . get_option( 'woocommerce_weight_unit', 'kg' ) ) . ''; } return " \n " . implode( "\n ", $f ) . "\n \n"; } /** * Variable products: only the variations are listed, each with g:item_group_id = parent ID. Google wants one item * per purchasable variant (own price, availability, colour/size, GTIN); the parent has no single price or stock, * so listing it too would duplicate the product and fail validation. */ public static function build(): string { $settings = Settings::all(); $treatment = 'treatment' === $settings['feed_scope']; $currency = get_woocommerce_currency(); $items = ''; $count = 0; foreach ( self::scope_products() as $p ) { if ( $count >= self::MAX_ITEMS ) { break; } // Belt and braces: in treatment scope a control product can never reach the feed. if ( $treatment && ! Settings::is_treatment( (int) $p->get_id() ) ) { continue; } try { if ( $p->is_type( 'variable' ) ) { foreach ( $p->get_children() as $vid ) { $v = wc_get_product( $vid ); if ( $v && 'publish' === $v->get_status() ) { $s = self::item( $v, $p, $currency ); if ( '' !== $s ) { $items .= $s; ++$count; } } } continue; } $s = self::item( $p, null, $currency ); if ( '' !== $s ) { $items .= $s; ++$count; } } catch ( \Throwable $e ) { Util::log( 'feed item ' . $p->get_id() . ': ' . $e->getMessage() ); } } $name = html_entity_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES, 'UTF-8' ); $desc = html_entity_decode( (string) get_bloginfo( 'description' ), ENT_QUOTES, 'UTF-8' ); $name = '' !== trim( $name ) ? $name : (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST ); return '' . "\n" . '' . "\n" . "\n" . ' ' . self::x( $name ) . "\n" . ' ' . self::x( home_url( '/' ) ) . "\n" . ' ' . self::x( '' !== trim( $desc ) ? $desc : $name . ' products' ) . "\n" . ' \n" . $items . "\n\n"; } }