Aucune description

class-wc-structured-data.php 17KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  1. <?php
  2. /**
  3. * Structured data's handler and generator using JSON-LD format.
  4. *
  5. * @package WooCommerce\Classes
  6. * @since 3.0.0
  7. * @version 3.0.0
  8. */
  9. defined( 'ABSPATH' ) || exit;
  10. /**
  11. * Structured data class.
  12. */
  13. class WC_Structured_Data {
  14. /**
  15. * Stores the structured data.
  16. *
  17. * @var array $_data Array of structured data.
  18. */
  19. private $_data = array();
  20. /**
  21. * Constructor.
  22. */
  23. public function __construct() {
  24. // Generate structured data.
  25. add_action( 'woocommerce_before_main_content', array( $this, 'generate_website_data' ), 30 );
  26. add_action( 'woocommerce_breadcrumb', array( $this, 'generate_breadcrumblist_data' ), 10 );
  27. add_action( 'woocommerce_single_product_summary', array( $this, 'generate_product_data' ), 60 );
  28. add_action( 'woocommerce_email_order_details', array( $this, 'generate_order_data' ), 20, 3 );
  29. // Output structured data.
  30. add_action( 'woocommerce_email_order_details', array( $this, 'output_email_structured_data' ), 30, 3 );
  31. add_action( 'wp_footer', array( $this, 'output_structured_data' ), 10 );
  32. }
  33. /**
  34. * Sets data.
  35. *
  36. * @param array $data Structured data.
  37. * @param bool $reset Unset data (default: false).
  38. * @return bool
  39. */
  40. public function set_data( $data, $reset = false ) {
  41. if ( ! isset( $data['@type'] ) || ! preg_match( '|^[a-zA-Z]{1,20}$|', $data['@type'] ) ) {
  42. return false;
  43. }
  44. if ( $reset && isset( $this->_data ) ) {
  45. unset( $this->_data );
  46. }
  47. $this->_data[] = $data;
  48. return true;
  49. }
  50. /**
  51. * Gets data.
  52. *
  53. * @return array
  54. */
  55. public function get_data() {
  56. return $this->_data;
  57. }
  58. /**
  59. * Structures and returns data.
  60. *
  61. * List of types available by default for specific request:
  62. *
  63. * 'product',
  64. * 'review',
  65. * 'breadcrumblist',
  66. * 'website',
  67. * 'order',
  68. *
  69. * @param array $types Structured data types.
  70. * @return array
  71. */
  72. public function get_structured_data( $types ) {
  73. $data = array();
  74. // Put together the values of same type of structured data.
  75. foreach ( $this->get_data() as $value ) {
  76. $data[ strtolower( $value['@type'] ) ][] = $value;
  77. }
  78. // Wrap the multiple values of each type inside a graph... Then add context to each type.
  79. foreach ( $data as $type => $value ) {
  80. $data[ $type ] = count( $value ) > 1 ? array( '@graph' => $value ) : $value[0];
  81. $data[ $type ] = apply_filters( 'woocommerce_structured_data_context', array( '@context' => 'https://schema.org/' ), $data, $type, $value ) + $data[ $type ];
  82. }
  83. // If requested types, pick them up... Finally change the associative array to an indexed one.
  84. $data = $types ? array_values( array_intersect_key( $data, array_flip( $types ) ) ) : array_values( $data );
  85. if ( ! empty( $data ) ) {
  86. if ( 1 < count( $data ) ) {
  87. $data = apply_filters( 'woocommerce_structured_data_context', array( '@context' => 'https://schema.org/' ), $data, '', '' ) + array( '@graph' => $data );
  88. } else {
  89. $data = $data[0];
  90. }
  91. }
  92. return $data;
  93. }
  94. /**
  95. * Get data types for pages.
  96. *
  97. * @return array
  98. */
  99. protected function get_data_type_for_page() {
  100. $types = array();
  101. $types[] = is_shop() || is_product_category() || is_product() ? 'product' : '';
  102. $types[] = is_shop() && is_front_page() ? 'website' : '';
  103. $types[] = is_product() ? 'review' : '';
  104. $types[] = 'breadcrumblist';
  105. $types[] = 'order';
  106. return array_filter( apply_filters( 'woocommerce_structured_data_type_for_page', $types ) );
  107. }
  108. /**
  109. * Makes sure email structured data only outputs on non-plain text versions.
  110. *
  111. * @param WP_Order $order Order data.
  112. * @param bool $sent_to_admin Send to admin (default: false).
  113. * @param bool $plain_text Plain text email (default: false).
  114. */
  115. public function output_email_structured_data( $order, $sent_to_admin = false, $plain_text = false ) {
  116. if ( $plain_text ) {
  117. return;
  118. }
  119. echo '<div style="display: none; font-size: 0; max-height: 0; line-height: 0; padding: 0; mso-hide: all;">';
  120. $this->output_structured_data();
  121. echo '</div>';
  122. }
  123. /**
  124. * Sanitizes, encodes and outputs structured data.
  125. *
  126. * Hooked into `wp_footer` action hook.
  127. * Hooked into `woocommerce_email_order_details` action hook.
  128. */
  129. public function output_structured_data() {
  130. $types = $this->get_data_type_for_page();
  131. $data = $this->get_structured_data( $types );
  132. if ( $data ) {
  133. echo '<script type="application/ld+json">' . wc_esc_json( wp_json_encode( $data ), true ) . '</script>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
  134. }
  135. }
  136. /*
  137. |--------------------------------------------------------------------------
  138. | Generators
  139. |--------------------------------------------------------------------------
  140. |
  141. | Methods for generating specific structured data types:
  142. |
  143. | - Product
  144. | - Review
  145. | - BreadcrumbList
  146. | - WebSite
  147. | - Order
  148. |
  149. | The generated data is stored into `$this->_data`.
  150. | See the methods above for handling `$this->_data`.
  151. |
  152. */
  153. /**
  154. * Generates Product structured data.
  155. *
  156. * Hooked into `woocommerce_single_product_summary` action hook.
  157. *
  158. * @param WC_Product $product Product data (default: null).
  159. */
  160. public function generate_product_data( $product = null ) {
  161. if ( ! is_object( $product ) ) {
  162. global $product;
  163. }
  164. if ( ! is_a( $product, 'WC_Product' ) ) {
  165. return;
  166. }
  167. $shop_name = get_bloginfo( 'name' );
  168. $shop_url = home_url();
  169. $currency = get_woocommerce_currency();
  170. $permalink = get_permalink( $product->get_id() );
  171. $image = wp_get_attachment_url( $product->get_image_id() );
  172. $markup = array(
  173. '@type' => 'Product',
  174. '@id' => $permalink . '#product', // Append '#product' to differentiate between this @id and the @id generated for the Breadcrumblist.
  175. 'name' => wp_kses_post( $product->get_name() ),
  176. 'url' => $permalink,
  177. 'description' => wp_strip_all_tags( do_shortcode( $product->get_short_description() ? $product->get_short_description() : $product->get_description() ) ),
  178. );
  179. if ( $image ) {
  180. $markup['image'] = $image;
  181. }
  182. // Declare SKU or fallback to ID.
  183. if ( $product->get_sku() ) {
  184. $markup['sku'] = $product->get_sku();
  185. } else {
  186. $markup['sku'] = $product->get_id();
  187. }
  188. if ( '' !== $product->get_price() ) {
  189. // Assume prices will be valid until the end of next year, unless on sale and there is an end date.
  190. $price_valid_until = gmdate( 'Y-12-31', time() + YEAR_IN_SECONDS );
  191. if ( $product->is_type( 'variable' ) ) {
  192. $lowest = $product->get_variation_price( 'min', false );
  193. $highest = $product->get_variation_price( 'max', false );
  194. if ( $lowest === $highest ) {
  195. $markup_offer = array(
  196. '@type' => 'Offer',
  197. 'price' => wc_format_decimal( $lowest, wc_get_price_decimals() ),
  198. 'priceValidUntil' => $price_valid_until,
  199. 'priceSpecification' => array(
  200. 'price' => wc_format_decimal( $lowest, wc_get_price_decimals() ),
  201. 'priceCurrency' => $currency,
  202. 'valueAddedTaxIncluded' => wc_prices_include_tax() ? 'true' : 'false',
  203. ),
  204. );
  205. } else {
  206. $markup_offer = array(
  207. '@type' => 'AggregateOffer',
  208. 'lowPrice' => wc_format_decimal( $lowest, wc_get_price_decimals() ),
  209. 'highPrice' => wc_format_decimal( $highest, wc_get_price_decimals() ),
  210. 'offerCount' => count( $product->get_children() ),
  211. );
  212. }
  213. } else {
  214. if ( $product->is_on_sale() && $product->get_date_on_sale_to() ) {
  215. $price_valid_until = gmdate( 'Y-m-d', $product->get_date_on_sale_to()->getTimestamp() );
  216. }
  217. $markup_offer = array(
  218. '@type' => 'Offer',
  219. 'price' => wc_format_decimal( $product->get_price(), wc_get_price_decimals() ),
  220. 'priceValidUntil' => $price_valid_until,
  221. 'priceSpecification' => array(
  222. 'price' => wc_format_decimal( $product->get_price(), wc_get_price_decimals() ),
  223. 'priceCurrency' => $currency,
  224. 'valueAddedTaxIncluded' => wc_prices_include_tax() ? 'true' : 'false',
  225. ),
  226. );
  227. }
  228. $markup_offer += array(
  229. 'priceCurrency' => $currency,
  230. 'availability' => 'http://schema.org/' . ( $product->is_in_stock() ? 'InStock' : 'OutOfStock' ),
  231. 'url' => $permalink,
  232. 'seller' => array(
  233. '@type' => 'Organization',
  234. 'name' => $shop_name,
  235. 'url' => $shop_url,
  236. ),
  237. );
  238. $markup['offers'] = array( apply_filters( 'woocommerce_structured_data_product_offer', $markup_offer, $product ) );
  239. }
  240. if ( $product->get_rating_count() && wc_review_ratings_enabled() ) {
  241. $markup['aggregateRating'] = array(
  242. '@type' => 'AggregateRating',
  243. 'ratingValue' => $product->get_average_rating(),
  244. 'reviewCount' => $product->get_review_count(),
  245. );
  246. // Markup 5 most recent rating/review.
  247. $comments = get_comments(
  248. array(
  249. 'number' => 5,
  250. 'post_id' => $product->get_id(),
  251. 'status' => 'approve',
  252. 'post_status' => 'publish',
  253. 'post_type' => 'product',
  254. 'parent' => 0,
  255. 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
  256. array(
  257. 'key' => 'rating',
  258. 'type' => 'NUMERIC',
  259. 'compare' => '>',
  260. 'value' => 0,
  261. ),
  262. ),
  263. )
  264. );
  265. if ( $comments ) {
  266. $markup['review'] = array();
  267. foreach ( $comments as $comment ) {
  268. $markup['review'][] = array(
  269. '@type' => 'Review',
  270. 'reviewRating' => array(
  271. '@type' => 'Rating',
  272. 'bestRating' => '5',
  273. 'ratingValue' => get_comment_meta( $comment->comment_ID, 'rating', true ),
  274. 'worstRating' => '1',
  275. ),
  276. 'author' => array(
  277. '@type' => 'Person',
  278. 'name' => get_comment_author( $comment ),
  279. ),
  280. 'reviewBody' => get_comment_text( $comment ),
  281. 'datePublished' => get_comment_date( 'c', $comment ),
  282. );
  283. }
  284. }
  285. }
  286. // Check we have required data.
  287. if ( empty( $markup['aggregateRating'] ) && empty( $markup['offers'] ) && empty( $markup['review'] ) ) {
  288. return;
  289. }
  290. $this->set_data( apply_filters( 'woocommerce_structured_data_product', $markup, $product ) );
  291. }
  292. /**
  293. * Generates Review structured data.
  294. *
  295. * Hooked into `woocommerce_review_meta` action hook.
  296. *
  297. * @param WP_Comment $comment Comment data.
  298. */
  299. public function generate_review_data( $comment ) {
  300. $markup = array();
  301. $markup['@type'] = 'Review';
  302. $markup['@id'] = get_comment_link( $comment->comment_ID );
  303. $markup['datePublished'] = get_comment_date( 'c', $comment->comment_ID );
  304. $markup['description'] = get_comment_text( $comment->comment_ID );
  305. $markup['itemReviewed'] = array(
  306. '@type' => 'Product',
  307. 'name' => get_the_title( $comment->comment_post_ID ),
  308. );
  309. // Skip replies unless they have a rating.
  310. $rating = get_comment_meta( $comment->comment_ID, 'rating', true );
  311. if ( $rating ) {
  312. $markup['reviewRating'] = array(
  313. '@type' => 'Rating',
  314. 'bestRating' => '5',
  315. 'ratingValue' => $rating,
  316. 'worstRating' => '1',
  317. );
  318. } elseif ( $comment->comment_parent ) {
  319. return;
  320. }
  321. $markup['author'] = array(
  322. '@type' => 'Person',
  323. 'name' => get_comment_author( $comment->comment_ID ),
  324. );
  325. $this->set_data( apply_filters( 'woocommerce_structured_data_review', $markup, $comment ) );
  326. }
  327. /**
  328. * Generates BreadcrumbList structured data.
  329. *
  330. * Hooked into `woocommerce_breadcrumb` action hook.
  331. *
  332. * @param WC_Breadcrumb $breadcrumbs Breadcrumb data.
  333. */
  334. public function generate_breadcrumblist_data( $breadcrumbs ) {
  335. $crumbs = $breadcrumbs->get_breadcrumb();
  336. if ( empty( $crumbs ) || ! is_array( $crumbs ) ) {
  337. return;
  338. }
  339. $markup = array();
  340. $markup['@type'] = 'BreadcrumbList';
  341. $markup['itemListElement'] = array();
  342. foreach ( $crumbs as $key => $crumb ) {
  343. $markup['itemListElement'][ $key ] = array(
  344. '@type' => 'ListItem',
  345. 'position' => $key + 1,
  346. 'item' => array(
  347. 'name' => $crumb[0],
  348. ),
  349. );
  350. if ( ! empty( $crumb[1] ) ) {
  351. $markup['itemListElement'][ $key ]['item'] += array( '@id' => $crumb[1] );
  352. } elseif ( isset( $_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI'] ) ) {
  353. $current_url = set_url_scheme( 'http://' . wp_unslash( $_SERVER['HTTP_HOST'] ) . wp_unslash( $_SERVER['REQUEST_URI'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
  354. $markup['itemListElement'][ $key ]['item'] += array( '@id' => $current_url );
  355. }
  356. }
  357. $this->set_data( apply_filters( 'woocommerce_structured_data_breadcrumblist', $markup, $breadcrumbs ) );
  358. }
  359. /**
  360. * Generates WebSite structured data.
  361. *
  362. * Hooked into `woocommerce_before_main_content` action hook.
  363. */
  364. public function generate_website_data() {
  365. $markup = array();
  366. $markup['@type'] = 'WebSite';
  367. $markup['name'] = get_bloginfo( 'name' );
  368. $markup['url'] = home_url();
  369. $markup['potentialAction'] = array(
  370. '@type' => 'SearchAction',
  371. 'target' => home_url( '?s={search_term_string}&post_type=product' ),
  372. 'query-input' => 'required name=search_term_string',
  373. );
  374. $this->set_data( apply_filters( 'woocommerce_structured_data_website', $markup ) );
  375. }
  376. /**
  377. * Generates Order structured data.
  378. *
  379. * Hooked into `woocommerce_email_order_details` action hook.
  380. *
  381. * @param WP_Order $order Order data.
  382. * @param bool $sent_to_admin Send to admin (default: false).
  383. * @param bool $plain_text Plain text email (default: false).
  384. */
  385. public function generate_order_data( $order, $sent_to_admin = false, $plain_text = false ) {
  386. if ( $plain_text || ! is_a( $order, 'WC_Order' ) ) {
  387. return;
  388. }
  389. $shop_name = get_bloginfo( 'name' );
  390. $shop_url = home_url();
  391. $order_url = $sent_to_admin ? $order->get_edit_order_url() : $order->get_view_order_url();
  392. $order_statuses = array(
  393. 'pending' => 'https://schema.org/OrderPaymentDue',
  394. 'processing' => 'https://schema.org/OrderProcessing',
  395. 'on-hold' => 'https://schema.org/OrderProblem',
  396. 'completed' => 'https://schema.org/OrderDelivered',
  397. 'cancelled' => 'https://schema.org/OrderCancelled',
  398. 'refunded' => 'https://schema.org/OrderReturned',
  399. 'failed' => 'https://schema.org/OrderProblem',
  400. );
  401. $markup_offers = array();
  402. foreach ( $order->get_items() as $item ) {
  403. if ( ! apply_filters( 'woocommerce_order_item_visible', true, $item ) ) {
  404. continue;
  405. }
  406. $product = $item->get_product();
  407. $product_exists = is_object( $product );
  408. $is_visible = $product_exists && $product->is_visible();
  409. $markup_offers[] = array(
  410. '@type' => 'Offer',
  411. 'price' => $order->get_line_subtotal( $item ),
  412. 'priceCurrency' => $order->get_currency(),
  413. 'priceSpecification' => array(
  414. 'price' => $order->get_line_subtotal( $item ),
  415. 'priceCurrency' => $order->get_currency(),
  416. 'eligibleQuantity' => array(
  417. '@type' => 'QuantitativeValue',
  418. 'value' => apply_filters( 'woocommerce_email_order_item_quantity', $item->get_quantity(), $item ),
  419. ),
  420. ),
  421. 'itemOffered' => array(
  422. '@type' => 'Product',
  423. 'name' => wp_kses_post( apply_filters( 'woocommerce_order_item_name', $item->get_name(), $item, $is_visible ) ),
  424. 'sku' => $product_exists ? $product->get_sku() : '',
  425. 'image' => $product_exists ? wp_get_attachment_image_url( $product->get_image_id() ) : '',
  426. 'url' => $is_visible ? get_permalink( $product->get_id() ) : get_home_url(),
  427. ),
  428. 'seller' => array(
  429. '@type' => 'Organization',
  430. 'name' => $shop_name,
  431. 'url' => $shop_url,
  432. ),
  433. );
  434. }
  435. $markup = array();
  436. $markup['@type'] = 'Order';
  437. $markup['url'] = $order_url;
  438. $markup['orderStatus'] = isset( $order_statuses[ $order->get_status() ] ) ? $order_statuses[ $order->get_status() ] : '';
  439. $markup['orderNumber'] = $order->get_order_number();
  440. $markup['orderDate'] = $order->get_date_created()->format( 'c' );
  441. $markup['acceptedOffer'] = $markup_offers;
  442. $markup['discount'] = $order->get_total_discount();
  443. $markup['discountCurrency'] = $order->get_currency();
  444. $markup['price'] = $order->get_total();
  445. $markup['priceCurrency'] = $order->get_currency();
  446. $markup['priceSpecification'] = array(
  447. 'price' => $order->get_total(),
  448. 'priceCurrency' => $order->get_currency(),
  449. 'valueAddedTaxIncluded' => 'true',
  450. );
  451. $markup['billingAddress'] = array(
  452. '@type' => 'PostalAddress',
  453. 'name' => $order->get_formatted_billing_full_name(),
  454. 'streetAddress' => $order->get_billing_address_1(),
  455. 'postalCode' => $order->get_billing_postcode(),
  456. 'addressLocality' => $order->get_billing_city(),
  457. 'addressRegion' => $order->get_billing_state(),
  458. 'addressCountry' => $order->get_billing_country(),
  459. 'email' => $order->get_billing_email(),
  460. 'telephone' => $order->get_billing_phone(),
  461. );
  462. $markup['customer'] = array(
  463. '@type' => 'Person',
  464. 'name' => $order->get_formatted_billing_full_name(),
  465. );
  466. $markup['merchant'] = array(
  467. '@type' => 'Organization',
  468. 'name' => $shop_name,
  469. 'url' => $shop_url,
  470. );
  471. $markup['potentialAction'] = array(
  472. '@type' => 'ViewAction',
  473. 'name' => 'View Order',
  474. 'url' => $order_url,
  475. 'target' => $order_url,
  476. );
  477. $this->set_data( apply_filters( 'woocommerce_structured_data_order', $markup, $sent_to_admin, $order ), true );
  478. }
  479. }