PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
← All changes | app/Services/SEOService.php +267 -21 3.0.2.8 → 3.0.16 View file →
@@ -140,10 +140,9 @@
140 140 $this->outputSchemaMarkup();
141 141
142 142 } catch (\Exception $e) {
143 143 // Log error and fail gracefully
144 - error_log('Yatra SEO Service Error: ' . $e->getMessage());
145 -
144 +
146 145 // Output basic fallback meta tags
147 146 $this->outputFallbackMetaTags();
148 147 }
149 148 }
@@ -164,9 +163,8 @@
164 163 }
165 164
166 165 return '';
167 166 } catch (\Exception $e) {
168 - error_log('Yatra SEO Service getTitle Error: ' . $e->getMessage());
169 167 return '';
170 168 }
171 169 }
172 170
@@ -235,8 +233,30 @@
235 233 return wp_strip_all_tags(strip_tags($text));
236 234 }
237 235
238 236 /**
237 + * Expand SEO meta placeholders for taxonomy terms.
238 + *
239 + * The destination / activity / category meta forms tell the operator to
240 + * "Use {name} as placeholder" in the meta title, description and keywords.
241 + * Those values are stored verbatim (the term may be renamed later), so the
242 + * token must be substituted here, at render time, with the term's name.
243 + * No-op when the value contains no token, so plain values are untouched.
244 + *
245 + * @param string $value Stored meta value, possibly containing {name}
246 + * @param string $name Term name to substitute
247 + * @return string Value with {name} replaced
248 + */
249 + private function expandTermTokens(string $value, string $name): string
250 + {
251 + if ($value === '' || strpos($value, '{') === false) {
252 + return $value;
253 + }
254 +
255 + return str_replace(['{name}', '{Name}'], $name, $value);
256 + }
257 +
258 + /**
239 259 * Validate and sanitize URL
240 260 *
241 261 * @param string $url URL to validate
242 262 * @return string Validated URL
@@ -280,10 +300,9 @@
280 300 default:
281 301 throw new \InvalidArgumentException("Unsupported page type: {$this->pageType}");
282 302 }
283 303 } catch (\Exception $e) {
284 - error_log('Yatra SEO Service collectSEOData Error: ' . $e->getMessage());
285 -
304 +
286 305 // Set default values on error
287 306 $this->setDefaultSEOData();
288 307 }
289 308 }
@@ -344,9 +363,8 @@
344 363 $this->seoData['author'] = $this->sanitizeText(get_bloginfo('name'));
345 364 $this->seoData['publisher'] = $this->sanitizeText(get_bloginfo('name'));
346 365
347 366 } catch (\Exception $e) {
348 - error_log('Yatra SEO Service collectTripArchiveData Error: ' . $e->getMessage());
349 367 $this->setDefaultSEOData();
350 368 }
351 369 }
352 370
@@ -417,13 +435,15 @@
417 435 $metadata = \is_array($maybe) ? $maybe : [];
418 436 }
419 437 }
420 438
439 + $name = (string) ($destination->name ?? '');
421 440 $title = $metadata['seo_title'] ?? $destination->name ?? '';
422 - $this->seoData['title'] = $this->sanitizeText((string) $title);
441 + $this->seoData['title'] = $this->sanitizeText($this->expandTermTokens((string) $title, $name));
423 442 $descRaw = $metadata['seo_description'] ?? $destination->description ?? '';
424 - $this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags((string) $descRaw), 45, '…'));
425 - $this->seoData['keywords'] = $this->sanitizeText((string) ($metadata['seo_keywords'] ?? ''));
443 + $descRaw = $this->expandTermTokens((string) $descRaw, $name);
444 + $this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags($descRaw), 45, '…'));
445 + $this->seoData['keywords'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_keywords'] ?? ''), $name));
426 446
427 447 // Get featured image or gallery image
428 448 $this->seoData['image'] = $destination->featured_image_url ??
429 449 ($destination->gallery_images[0] ?? '');
@@ -456,12 +476,14 @@
456 476 $metadata = \is_array($maybe) ? $maybe : [];
457 477 }
458 478 }
459 479
460 - $this->seoData['title'] = $this->sanitizeText((string) ($metadata['seo_title'] ?? $activity->name ?? ''));
480 + $name = (string) ($activity->name ?? '');
481 + $this->seoData['title'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_title'] ?? $activity->name ?? ''), $name));
461 482 $descRaw = $metadata['seo_description'] ?? $activity->description ?? '';
462 - $this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags((string) $descRaw), 45, '…'));
463 - $this->seoData['keywords'] = $this->sanitizeText((string) ($metadata['seo_keywords'] ?? ''));
483 + $descRaw = $this->expandTermTokens((string) $descRaw, $name);
484 + $this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags($descRaw), 45, '…'));
485 + $this->seoData['keywords'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_keywords'] ?? ''), $name));
464 486
465 487 // Get featured image or gallery image
466 488 $this->seoData['image'] = $activity->featured_image_url ??
467 489 ($activity->gallery_images[0] ?? '');
@@ -494,12 +516,14 @@
494 516 $metadata = \is_array($maybe) ? $maybe : [];
495 517 }
496 518 }
497 519
498 - $this->seoData['title'] = $this->sanitizeText((string) ($metadata['seo_title'] ?? $category->name ?? ''));
520 + $name = (string) ($category->name ?? '');
521 + $this->seoData['title'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_title'] ?? $category->name ?? ''), $name));
499 522 $descRaw = $metadata['seo_description'] ?? $category->description ?? '';
500 - $this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags((string) $descRaw), 45, '…'));
501 - $this->seoData['keywords'] = $this->sanitizeText((string) ($metadata['seo_keywords'] ?? ''));
523 + $descRaw = $this->expandTermTokens((string) $descRaw, $name);
524 + $this->seoData['description'] = $this->sanitizeText(\wp_trim_words(\wp_strip_all_tags($descRaw), 45, '…'));
525 + $this->seoData['keywords'] = $this->sanitizeText($this->expandTermTokens((string) ($metadata['seo_keywords'] ?? ''), $name));
502 526
503 527 // Get featured image or gallery image
504 528 $this->seoData['image'] = $category->featured_image_url ??
505 529 ($category->gallery_images[0] ?? '');
@@ -606,8 +630,46 @@
606 630 return $raw !== '' ? $this->validateUrl($raw) : '';
607 631 }
608 632
609 633 /**
634 + * The page's language as WordPress reports it, reduced to what hreflang
635 + * and Open Graph accept: language + optional region (`de-DE`, `en-US`,
636 + * `ca`). Read at render time through get_locale()/the `locale` filter, so
637 + * a German install, WPML and Polylang per-page languages all resolve
638 + * correctly. WordPress variant locales such as `de_DE_formal` ("Deutsch
639 + * (Sie)") or `pt_PT_ao90` carry a third segment that is not a region —
640 + * Google ignores an hreflang like `de-DE-formal` and Facebook rejects
641 + * `de_DE_formal` — so only the first two segments are kept. Falls back to
642 + * `en-US` when WP has no locale, which keeps existing English sites
643 + * byte-identical.
644 + */
645 + private function languageTag(): string
646 + {
647 + $locale = (string) get_locale();
648 + if ($locale === '') {
649 + $locale = str_replace('-', '_', (string) get_bloginfo('language'));
650 + }
651 +
652 + $parts = preg_split('/[_-]/', $locale) ?: [];
653 + $language = strtolower((string) ($parts[0] ?? ''));
654 + if (!preg_match('/^[a-z]{2,3}$/', $language)) {
655 + return 'en-US';
656 + }
657 +
658 + $region = strtoupper((string) ($parts[1] ?? ''));
659 +
660 + return preg_match('/^[A-Z]{2}$/', $region) ? $language . '-' . $region : $language;
661 + }
662 +
663 + /**
664 + * Same language in Open Graph form (`de_DE`, `en_US`).
665 + */
666 + private function ogLocale(): string
667 + {
668 + return str_replace('-', '_', $this->languageTag());
669 + }
670 +
671 + /**
610 672 * Output basic meta tags
611 673 */
612 674 private function outputBasicMetaTags(): void
613 675 {
@@ -624,9 +686,9 @@
624 686 * Output Open Graph meta tags
625 687 */
626 688 private function outputOpenGraphTags(): void
627 689 {
628 - echo '<meta property="og:locale" content="en_US">' . "\n";
690 + echo '<meta property="og:locale" content="' . esc_attr($this->ogLocale()) . '">' . "\n";
629 691 echo '<meta property="og:site_name" content="' . esc_attr(get_bloginfo('name')) . '">' . "\n";
630 692 echo '<meta property="og:title" content="' . esc_attr($this->seoData['title']) . '">' . "\n";
631 693 echo '<meta property="og:description" content="' . esc_attr($this->truncateText($this->seoData['description'], 160)) . '">' . "\n";
632 694 echo '<meta property="og:type" content="' . esc_attr($this->seoData['type']) . '">' . "\n";
@@ -662,18 +724,81 @@
662 724 }
663 725 }
664 726
665 727 /**
728 + * The robots directive for this page.
729 + *
730 + * Normally "index, follow" with the usual snippet hints, exactly as before.
731 + * When the operator has both dropped this content type from the sitemap and
732 + * opted in to noindexing excluded types, it becomes "noindex, follow" —
733 + * because removing a URL from a sitemap does not stop Google indexing it,
734 + * and this page was previously asserting the opposite.
735 + *
736 + * "follow" is kept deliberately: the page should stop being indexed, but
737 + * links out of it (to trips that ARE published) should still be crawled.
738 + */
739 + private function robotsDirective(): string
740 + {
741 + $indexable = 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1';
742 +
743 + if (!SettingsService::isEnabled('sitemap_noindex_excluded')) {
744 + return $indexable;
745 + }
746 +
747 + $sitemapType = $this->sitemapTypeForPage();
748 + if ($sitemapType === '') {
749 + return $indexable;
750 + }
751 +
752 + if (!class_exists(\Yatra\Sitemap\SitemapService::class)) {
753 + return $indexable;
754 + }
755 +
756 + $sitemap = new \Yatra\Sitemap\SitemapService();
757 +
758 + return $sitemap->isTypeEnabled($sitemapType) ? $indexable : 'noindex, follow';
759 + }
760 +
761 + /**
762 + * Map this page onto the sitemap content type that governs it.
763 + *
764 + * The taxonomy listing pages are published by the sitemap's `archive` type
765 + * (the trip listing plus each taxonomy index), so they follow it.
766 + *
767 + * @return string '' when no sitemap type owns this page.
768 + */
769 + private function sitemapTypeForPage(): string
770 + {
771 + switch ($this->pageType) {
772 + case self::PAGE_TYPE_TRIP:
773 + return \Yatra\Sitemap\SitemapService::TYPE_TRIP;
774 + case self::PAGE_TYPE_DESTINATION:
775 + return \Yatra\Sitemap\SitemapService::TYPE_DESTINATION;
776 + case self::PAGE_TYPE_ACTIVITY:
777 + return \Yatra\Sitemap\SitemapService::TYPE_ACTIVITY;
778 + case self::PAGE_TYPE_CATEGORY:
779 + return \Yatra\Sitemap\SitemapService::TYPE_CATEGORY;
780 + case self::PAGE_TYPE_TRIP_ARCHIVE:
781 + case self::PAGE_TYPE_DESTINATION_LISTING:
782 + case self::PAGE_TYPE_ACTIVITY_LISTING:
783 + case self::PAGE_TYPE_CATEGORY_LISTING:
784 + return \Yatra\Sitemap\SitemapService::TYPE_ARCHIVE;
785 + default:
786 + return '';
787 + }
788 + }
789 +
790 + /**
666 791 * Output advanced meta tags
667 792 */
668 793 private function outputAdvancedMetaTags(): void
669 794 {
670 - echo '<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1">' . "\n";
795 + echo '<meta name="robots" content="' . esc_attr($this->robotsDirective()) . '">' . "\n";
671 796 echo '<link rel="canonical" href="' . esc_url($this->seoData['url']) . '">' . "\n";
672 797 echo '<meta name="author" content="' . esc_attr($this->seoData['author']) . '">' . "\n";
673 798 echo '<meta name="publisher" content="' . esc_attr($this->seoData['publisher']) . '">' . "\n";
674 799 echo '<meta name="lastmod" content="' . esc_attr($this->seoData['modified_time']) . '">' . "\n";
675 - echo '<link rel="alternate" hreflang="en-US" href="' . esc_url($this->seoData['url']) . '">' . "\n";
800 + echo '<link rel="alternate" hreflang="' . esc_attr($this->languageTag()) . '" href="' . esc_url($this->seoData['url']) . '">' . "\n";
676 801 echo '<meta name="revisit-after" content="7 days">' . "\n";
677 802 echo '<meta name="distribution" content="global">' . "\n";
678 803 echo '<meta name="rating" content="general">' . "\n";
679 804 }
@@ -770,9 +895,13 @@
770 895 private function outputSchemaMarkup(): void
771 896 {
772 897 $schema = $this->generateSchemaMarkup();
773 898 if (!empty($schema)) {
774 - echo '<script type="application/ld+json">' . json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . '</script>' . "\n";
899 + // JSON_HEX_TAG|JSON_HEX_AMP escape < > & as \u00xx so no string value
900 + // (e.g. user-submitted review text/author) can break out of this
901 + // <script> block — a </script> in a review would otherwise be XSS.
902 + // Google parses the \u-escaped JSON identically.
903 + echo '<script type="application/ld+json">' . json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) . '</script>' . "\n";
775 904 }
776 905 }
777 906
778 907 /**
@@ -791,9 +920,9 @@
791 920 'url' => home_url()
792 921 ],
793 922 'dateModified' => $this->seoData['modified_time'],
794 923 'datePublished' => $this->seoData['published_time'],
795 - 'inLanguage' => 'en-US',
924 + 'inLanguage' => $this->languageTag(),
796 925 'isPartOf' => [
797 926 '@type' => 'WebSite',
798 927 'name' => get_bloginfo('name'),
799 928 'url' => home_url()
@@ -812,11 +941,16 @@
812 941 case self::PAGE_TYPE_CATEGORY_LISTING:
813 942 return $this->generateCollectionPageSchema($baseSchema);
814 943 case self::PAGE_TYPE_DESTINATION:
815 944 return $this->generatePlaceSchema($baseSchema);
945 + case self::PAGE_TYPE_TRIP:
946 + // A tour with ratings/reviews must be a Google-supported review
947 + // type (Product) with the rating + reviews nested inside it —
948 + // NOT a TouristTrip/Article, which Google rejects for review
949 + // snippets ("invalid object type for parent_node").
950 + return $this->generateTripProductSchema($baseSchema);
816 951 case self::PAGE_TYPE_ACTIVITY:
817 952 case self::PAGE_TYPE_CATEGORY:
818 - case self::PAGE_TYPE_TRIP:
819 953 return $this->generateArticleSchema($baseSchema);
820 954 default:
821 955 return $baseSchema;
822 956 }
@@ -865,8 +999,120 @@
865 999 '@type' => 'Organization',
866 1000 'name' => get_bloginfo('name')
867 1001 ]
868 1002 ]);
1003 + }
1004 +
1005 + /**
1006 + * Generate Product schema for a single trip, with rating + reviews nested
1007 + * INSIDE the product (the structure Google requires for review snippets).
1008 + *
1009 + * `aggregateRating` and `review` are added only when approved reviews exist —
1010 + * an empty aggregateRating is itself a structured-data error. Each review
1011 + * carries the required author / reviewRating / reviewBody / datePublished so
1012 + * Google no longer reports "Missing field author" or "Missing field itemReviewed".
1013 + */
1014 + private function generateTripProductSchema(array $baseSchema): array
1015 + {
1016 + $trip = $this->pageObject;
1017 +
1018 + $tripId = (\is_object($trip) && isset($trip->id)) ? (int) $trip->id : 0;
1019 + $avg = (\is_object($trip) && \method_exists($trip, 'getAverageRating'))
1020 + ? (float) $trip->getAverageRating()
1021 + : 0.0;
1022 + $count = (\is_object($trip) && \method_exists($trip, 'getReviewCount'))
1023 + ? (int) $trip->getReviewCount()
1024 + : 0;
1025 +
1026 + // No approved reviews → keep the generic Article (unchanged behaviour).
1027 + // A Product is emitted ONLY when there's a real rating to nest, which is
1028 + // exactly what fixes the review markup — and it avoids emitting a bare
1029 + // Product (offers/review/rating all absent) that Google would flag as
1030 + // incomplete on the many tours that have no reviews yet.
1031 + if (!($tripId > 0 && $count > 0 && $avg > 0)) {
1032 + return $this->generateArticleSchema($baseSchema);
1033 + }
1034 +
1035 + $schema = [
1036 + '@context' => 'https://schema.org',
1037 + '@type' => 'Product',
1038 + 'name' => $this->seoData['title'],
1039 + 'url' => $this->seoData['url'],
1040 + 'brand' => [
1041 + '@type' => 'Brand',
1042 + 'name' => get_bloginfo('name'),
1043 + ],
1044 + ];
1045 + if (!empty($this->seoData['description'])) {
1046 + $schema['description'] = $this->seoData['description'];
1047 + }
1048 + if (!empty($this->seoData['image'])) {
1049 + $schema['image'] = $this->seoData['image'];
1050 + }
1051 +
1052 + $schema['aggregateRating'] = [
1053 + '@type' => 'AggregateRating',
1054 + 'ratingValue' => (string) round($avg, 1),
1055 + 'reviewCount' => (string) $count,
1056 + 'bestRating' => '5',
1057 + 'worstRating' => '1',
1058 + ];
1059 +
1060 + $rows = [];
1061 + try {
1062 + $rows = (new \Yatra\Repositories\ReviewRepository())->findApprovedByTripId($tripId, 10);
1063 + } catch (\Throwable $e) {
1064 + $rows = [];
1065 + }
1066 +
1067 + $reviews = [];
1068 + foreach ($rows as $row) {
1069 + $rating = (int) ($row->rating ?? 0);
1070 + if ($rating < 1 || $rating > 5) {
1071 + continue;
1072 + }
1073 + // Strip tags on every user-derived value — even though the emitter
1074 + // now \u-escapes < > &, keep the data itself clean/plain-text.
1075 + $authorName = trim(\wp_strip_all_tags((string) ($row->author_name ?? $row->user_display_name ?? '')));
1076 + if ($authorName === '') {
1077 + $authorName = __('Anonymous', 'yatra');
1078 + }
1079 +
1080 + $review = [
1081 + '@type' => 'Review',
1082 + 'author' => ['@type' => 'Person', 'name' => $authorName],
1083 + 'reviewRating' => [
1084 + '@type' => 'Rating',
1085 + 'ratingValue' => (string) $rating,
1086 + 'bestRating' => '5',
1087 + 'worstRating' => '1',
1088 + ],
1089 + ];
1090 +
1091 + $title = $this->sanitizeText(trim((string) ($row->title ?? '')));
1092 + if ($title !== '') {
1093 + $review['name'] = $title;
1094 + }
1095 + $body = $this->sanitizeText(trim(\wp_strip_all_tags((string) ($row->content ?? ''))));
1096 + if ($body !== '') {
1097 + $review['reviewBody'] = $body;
1098 + }
1099 + $created = (string) ($row->created_at ?? '');
1100 + if ($created !== '') {
1101 + $ts = strtotime($created);
1102 + if ($ts) {
1103 + $review['datePublished'] = date('Y-m-d', $ts);
1104 + }
1105 + }
1106 +
1107 + $reviews[] = $review;
1108 + }
1109 +
1110 + if (!empty($reviews)) {
1111 + $schema['review'] = $reviews;
1112 + }
1113 +
1114 + return $schema;
869 1115 }
870 1116
871 1117 /**
872 1118 * Truncate text to specified length