PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / 2.10.0
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More v2.10.0
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.7 2.6.8 2.6.5 2.6.4 2.6.3 2.6.2 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 trunk 1.0 1.0.1 1.0.2 1.0.3 1.1 1.1.1 1.1.2 1.2.0 2.0 2.1.0 2.1.1 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1
reviews-feed / class / Common / Parser.php
reviews-feed / class / Common Last commit date
Admin 3 weeks ago Builder 3 weeks ago Customizer 3 weeks ago Exceptions 3 weeks ago Helpers 3 weeks ago Integrations 3 weeks ago Migrations 3 weeks ago ReviewAlerts 3 weeks ago Services 3 weeks ago Settings 3 weeks ago Support 3 weeks ago Traits 3 weeks ago Utils 3 weeks ago AuthorizationStatusCheck.php 3 weeks ago BusinessDataCache.php 3 weeks ago Clear_Cache.php 3 weeks ago Container.php 3 weeks ago DisplayElements.php 3 weeks ago Email_Notification.php 3 weeks ago Error_Reporter.php 3 weeks ago Feed.php 3 weeks ago FeedCache.php 3 weeks ago FeedCacheUpdater.php 3 weeks ago FeedDisplay.php 3 weeks ago Feed_Locator.php 3 weeks ago Parser.php 3 weeks ago PostAggregator.php 3 weeks ago RemoteRequest.php 3 weeks ago SBR_Education.php 3 weeks ago SBR_Schema_Service.php 3 weeks ago SBR_Settings.php 3 weeks ago ServiceContainer.php 3 weeks ago SinglePostCache.php 3 weeks ago TemplateRenderer.php 3 weeks ago Tooltip_Wizard.php 3 weeks ago Util.php 3 weeks ago
Parser.php
478 lines
1 <?php
2
3 namespace SmashBalloon\Reviews\Common;
4
5 class Parser {
6 public function __construct()
7 {
8 }
9
10 public function get_id($post)
11 {
12 if (! empty($post['review_id'])) {
13 return (string) $post['review_id'];
14 }
15 if (! empty($post['id'])) {
16 return (string) $post['id'];
17 }
18 return '';
19 }
20
21 public function get_text($post)
22 {
23 if (! empty($post['text'])) {
24 // No decode here. Doing it a second time re-armed a stored
25 // &lt;img class=emoji alt=…&gt; into live markup after a write filter had already
26 // accepted it as inert text — the extra encoding layer every writer could hide
27 // behind (SMASH-1795).
28 //
29 // Do NOT "restore this for consistency with ingest": the write side does not
30 // decode everywhere, and that asymmetry is deliberate rather than an oversight
31 // to tidy up. ONE writer decodes: the bulk/builder path
32 // (SBR_Feed_Saver_Manager::cache_single_review). The on-demand frontend path —
33 // Feed::cache_single_posts_from_set (Feed.php:677, note the same method name on
34 // a different class) — writes straight through SinglePostCache, as do the
35 // Woo/EDD cache handlers, the bulk updaters and the review form
36 // (SubmissionsManager::get_db_store_data, which stores entities as-is on
37 // purpose: decoding there shifts a byte length two rule evaluators depend on).
38 //
39 // Util::normalize_review_shape() is not a fix location either: PostAggregator
40 // calls it on READ (:114, :186, :245), so decoding there would reintroduce
41 // exactly this bug.
42 //
43 // Consequence, accepted: a body stored DOUBLE-encoded by one of the
44 // non-decoding writers renders its entities literally. Single-encoded text is
45 // unaffected — wp_kses() leaves a valid entity alone and esc_html() passes it
46 // through ($double_encode = false), so the browser still resolves it. Verified
47 // against WordPress: wp_kses('Caf&eacute;', [...]) returns it unchanged, and
48 // esc_html('S&oslash;ren') returns 'S&oslash;ren', not '&amp;oslash;'.
49 //
50 // This is the FEED contract only. Two other surfaces read the same value and
51 // both decode it, each for its own reason. Do not "unify" any of the three in
52 // either direction without re-reading all the sink sets.
53 //
54 // Review Alerts — SBR_Review_Alert_Frontend:941, SBR_Review_Alert_Service:1347.
55 // Their sinks are textContent and escapeHTML(), neither of which resolves an
56 // entity, so an accented body would display "sm&oslash;rrebr&oslash;d"
57 // literally without the decode. Safe because both insert as TEXT.
58 //
59 // JSON-LD — SBR_Schema_Service::map_reviews() and ::entity_identity()
60 // (SMASH-1756). Same problem, worse: inside
61 // <script type="application/ld+json"> the HTML parser treats the block as raw
62 // text, so nothing ever resolves the entity. That surface decodes at its own
63 // boundary and does NOT strip markup — stripping would truncate a legitimate
64 // "cheaper than < $10" at the unclosed bracket, which
65 // test_map_preserves_review_text_without_truncation pins. What keeps a decoded
66 // tag inert there is per-sink escaping, JSON_HEX_TAG on the own-block path and
67 // escape_markup_for_aioseo() on the AIOSEO path — do not remove either on the
68 // assumption that the text was sanitised upstream.
69 return (string) $post['text'];
70 }
71 return '';
72 }
73
74
75 public function get_rating($post)
76 {
77 if (!empty($post['rating'])) {
78 if ($post['rating'] === 'positive') {
79 return 5;
80 } elseif ($post['rating'] === 'negative') {
81 return 1;
82 } else {
83 return (int) $post['rating'];
84 }
85 }
86 return 1;
87 }
88
89 public function get_time($post)
90 {
91 if (! empty($post['time'])) {
92 return $post['time'];
93 }
94 return 0;
95 }
96
97 public function get_reviewer_name($post)
98 {
99 if (! empty($post['reviewer']['name'])) {
100 // No decode, same as the review body above (SMASH-1795). Accented names are
101 // unaffected: every consumer runs esc_html(), which is _wp_specialchars() with
102 // $double_encode = false, so a stored `S&oslash;ren` reaches the browser
103 // untouched and renders as Søren. Verified against WordPress.
104 return (string) $post['reviewer']['name'];
105 }
106 return '';
107 }
108
109
110
111 public function get_provider_name($post_or_business)
112 {
113 if (! empty($post_or_business['provider']['name'])) {
114 return $post_or_business['provider']['name'];
115 }
116 return '';
117 }
118
119 public function get_business_id($post_or_business)
120 {
121 if (! empty($post_or_business['business']['id'])) {
122 return $post_or_business['business']['id'];
123 } elseif (! empty($post_or_business['id'])) {
124 return $post_or_business['id'];
125 }
126 return '';
127 }
128
129 public function get_business_name($post_or_business)
130 {
131 if (! empty($post_or_business['business']['name'])) {
132 return $post_or_business['business']['name'];
133 } elseif (! empty($post_or_business['name']) && is_string($post_or_business['name'])) {
134 return $post_or_business['name'];
135 }
136 return '';
137 }
138
139 public function get_average_rating($businesses)
140 {
141 if (is_array($businesses)) {
142 // SMASH-1412: prefer the plugin-computed dedup'd aggregate when
143 // present. EDD / WooCommerce multi-source feeds stamp this on every
144 // source.info so the first one wins. Older feeds without the field
145 // fall through to the per-source weighted mean below.
146 foreach ($businesses as $business) {
147 if (! empty($business['info']['feed_aggregated'])) {
148 return round(floatval($business['info']['feed_average_rating'] ?? 0), 1);
149 }
150 }
151
152 // SMASH-1583: weight each source's rating by its review count so a
153 // 3-review source can't swing the headline average as hard as a
154 // 219-review one. A multi-source feed (e.g. Google 219@4.7 +
155 // Facebook 3@5.0) must report sum(rating*count)/sum(count) = 4.7,
156 // not the unweighted mean round((4.7+5.0)/2, 1) = 4.9.
157 //
158 // Falls back to the legacy unweighted mean when no source reports a
159 // usable count (manual feeds, providers without a count) — this
160 // preserves the pre-1583 number and avoids a divide-by-zero / 0.0
161 // regression on feeds whose counts are all empty.
162 $weighted_sum = 0.0;
163 $weighted_n = 0;
164 $simple_sum = 0.0;
165 $simple_n = 0;
166 foreach ($businesses as $business) {
167 // Check for rating first, then fall back to average_rating for WooCommerce multi-product sources
168 $rating = $business['info']['rating'] ?? $business['info']['average_rating'] ?? 0;
169 if (empty($rating)) {
170 continue;
171 }
172 $count = intval($business['info']['total_rating'] ?? $business['info']['review_count'] ?? 0);
173 $weighted_sum += floatval($rating) * $count;
174 $weighted_n += $count;
175 $simple_sum += floatval($rating);
176 $simple_n += 1;
177 }
178
179 if ($weighted_n > 0) {
180 return round($weighted_sum / $weighted_n, 1);
181 }
182
183 $simple_n = $simple_n === 0 ? 1 : $simple_n;
184 return round($simple_sum / $simple_n, 1);
185 }
186 return '';
187 }
188
189 /**
190 * Backfill missing per-source review counts from the actual cached reviews.
191 *
192 * Some providers don't expose a reliable count in the source header. Facebook
193 * is the canonical case (SMASH-1583): modern Pages use recommendations, so
194 * FB Graph's legacy `rating_count` comes back 0, omitted, OR a stale low
195 * value (e.g. 1 while 2 recommendations are actually cached and displayed).
196 * The header then under-reports the combined count and the count-weighted
197 * average mistreats the Facebook source.
198 *
199 * For each header source we set the count to max(reported, cached-reviews),
200 * keyed by each post's `source.id`. Sources that report a true total (Google,
201 * Yelp, Trustpilot, …) always report >= what's cached, so they're left
202 * untouched — counting cached posts would UNDER-report them. Only a stale or
203 * empty reported count (Facebook) gets corrected up to the real cached number.
204 *
205 * @param array $businesses Header data (one entry per source, each with info.id).
206 * @param array $posts Full normalized cached reviews (each with source.id).
207 * @return array Header data with info.total_rating corrected where the cached count is higher.
208 */
209 public function backfill_review_counts($businesses, $posts)
210 {
211 if (! is_array($businesses)) {
212 return [];
213 }
214 if (empty($businesses)) {
215 return $businesses; // Nothing to backfill (e.g. single-manual-review header).
216 }
217 if (empty($posts) || ! is_array($posts)) {
218 return $businesses;
219 }
220
221 $counts_by_source = $this->tally_reviews_by_source($posts);
222 if (empty($counts_by_source)) {
223 return $businesses;
224 }
225
226 foreach ($businesses as $key => $business) {
227 $info = $business['info'] ?? null;
228 if (! is_array($info)) {
229 continue;
230 }
231 $id = isset($info['id']) ? (string) $info['id'] : '';
232 if ($id === '' || empty($counts_by_source[$id])) {
233 continue;
234 }
235 // Use the LARGER of the provider-reported count and the actual cached
236 // reviews. Providers that report a true total (Google / Yelp /
237 // Trustpilot / TripAdvisor / WP.org) always report >= what's cached,
238 // so they're left untouched — their real totals routinely exceed the
239 // cached page and must never be down-counted. Facebook is the problem
240 // case: its deprecated rating_count persists 0 OR a stale low value
241 // (e.g. 1 while 2 recommendations are cached and displayed), so the
242 // cached count is the honest floor and wins.
243 $reported = (int) ($info['total_rating'] ?? $info['review_count'] ?? 0);
244 $cached = (int) $counts_by_source[$id];
245 if ($cached > $reported) {
246 $businesses[$key]['info']['total_rating'] = $cached;
247 }
248 }
249 return $businesses;
250 }
251
252 /**
253 * Tally cached reviews per source id (keyed by each post's source.id).
254 *
255 * Shared by backfill_review_counts() and the customizer-preview enrichment
256 * so the count rule lives in exactly one place.
257 *
258 * @param array $posts Normalized cached reviews.
259 * @return array<string,int> source id => review count
260 */
261 public function tally_reviews_by_source($posts)
262 {
263 $counts_by_source = [];
264 if (! is_array($posts)) {
265 return $counts_by_source;
266 }
267 foreach ($posts as $post) {
268 if (! is_array($post)) {
269 continue;
270 }
271 $source = $post['source'] ?? null;
272 if (! is_array($source)) {
273 continue;
274 }
275 $source_id = (string) ($source['id'] ?? '');
276 if ($source_id === '') {
277 continue;
278 }
279 $counts_by_source[$source_id] = ($counts_by_source[$source_id] ?? 0) + 1;
280 }
281 return $counts_by_source;
282 }
283
284 public function get_num_ratings($businesses)
285 {
286 if (is_array($businesses)) {
287 // SMASH-1412: see get_average_rating() — prefer feed-level dedup.
288 foreach ($businesses as $business) {
289 if (! empty($business['info']['feed_aggregated'])) {
290 return intval($business['info']['feed_total_review_count'] ?? 0);
291 }
292 }
293
294 $total_rating = 0;
295 foreach ($businesses as $business) {
296 // Check for total_rating first, then fall back to review_count for WooCommerce multi-product sources
297 $count = $business['info']['total_rating'] ?? $business['info']['review_count'] ?? 0;
298 if (! empty($count)) {
299 $total_rating += intval($count);
300 }
301 }
302 return $total_rating;
303 }
304 return '';
305 }
306
307 public function get_max_rating($business)
308 {
309 if (! empty($business['max'])) {
310 return $business['max'];
311 }
312 return '';
313 }
314
315 public function get_rating_type($business)
316 {
317 if (! empty($business['type'])) {
318 return $business['type'];
319 }
320 return '';
321 }
322
323 public function get_business_image($business)
324 {
325 if (! empty($business['avatar'])) {
326 return $business['avatar'];
327 }
328 return '';
329 }
330
331 public function get_review_url($business, $source)
332 {
333
334 if (! empty($business['review_url'])) {
335 return $business['review_url'];
336 }
337 if (! empty($business['info']['url'])) {
338 if (strpos($business['info']['url'], 'https://www.facebook.com') === 0) {
339 return $this->convert_to_fb_review_url($business['info']['url']);
340 } elseif (strpos($business['info']['url'], 'https://www.yelp.com') === 0) {
341 return $this->convert_to_yelp_review_url($business['info']['url']);
342 } elseif (strpos($business['info']['url'], 'https://www.tripadvisor.com') === 0) {
343 return $this->convert_to_tripadvisor_review_url($business['info']['url']);
344 } elseif (isset($source['provider']) && $source['provider'] === 'woocommerce') {
345 // WooCommerce single product - add #reviews anchor
346 return $this->convert_to_woocommerce_review_url($business['info']['url']);
347 }
348 return $business['info']['url'];
349 } else {
350 if (isset($source['provider']) && $source['provider'] === 'google') {
351 return $this->convert_to_google_review_url($source['account_id']);
352 }
353
354 // Handle WooCommerce multi-product sources - link to first product's review section
355 if (isset($source['provider']) && $source['provider'] === 'woocommerce') {
356 return $this->get_woocommerce_review_url($source);
357 }
358 }
359
360 return '';
361 }
362
363 /**
364 * Get the review URL for WooCommerce sources.
365 *
366 * For single product sources, returns the product URL with #reviews anchor.
367 * For multi-product sources, returns the first product's URL with #reviews anchor.
368 *
369 * @since 2.4.0
370 * @param array $source The source data.
371 * @return string The review URL or empty string if not available.
372 */
373 public function get_woocommerce_review_url($source)
374 {
375 // Decode info if it's a JSON string
376 $info = $source['info'] ?? [];
377 if (is_string($info)) {
378 $info = json_decode($info, true);
379 if (! is_array($info)) {
380 $info = [];
381 }
382 }
383
384 // Check for single product source URL first
385 if (! empty($info['url'])) {
386 return $this->convert_to_woocommerce_review_url($info['url']);
387 }
388
389 // For multi-product sources, use the first product's URL from direct_products (has URLs)
390 // or products array as fallback
391 if (! empty($info['direct_products']) && is_array($info['direct_products'])) {
392 $first_product = $info['direct_products'][0] ?? [];
393 if (! empty($first_product['url'])) {
394 return $this->convert_to_woocommerce_review_url($first_product['url']);
395 }
396 }
397
398 // Fallback to products array
399 if (! empty($info['products']) && is_array($info['products'])) {
400 $first_product = $info['products'][0] ?? [];
401 if (! empty($first_product['url'])) {
402 return $this->convert_to_woocommerce_review_url($first_product['url']);
403 }
404 }
405
406 // Fallback: try source URL directly
407 if (! empty($source['url'])) {
408 return $this->convert_to_woocommerce_review_url($source['url']);
409 }
410
411 return '';
412 }
413
414 /**
415 * Convert a WooCommerce product URL to a review URL by adding #reviews anchor.
416 *
417 * @since 2.4.0
418 * @param string $url The product URL.
419 * @return string The URL with #reviews anchor.
420 */
421 public function convert_to_woocommerce_review_url($url)
422 {
423 // Remove any existing fragment
424 $url = preg_replace('/#.*$/', '', $url);
425
426 // Add #reviews anchor (standard WooCommerce review tab anchor)
427 return trailingslashit($url) . '#reviews';
428 }
429
430 public function convert_to_google_review_url($account_id)
431 {
432 return "https://search.google.com/local/writereview?placeid=" . $account_id;
433 }
434
435 public function convert_to_fb_review_url($url)
436 {
437 if (strpos($url, 'reviews') === false) {
438 return trailingslashit($url) . 'reviews';
439 }
440
441 return $url;
442 }
443
444 public function convert_to_yelp_review_url($url)
445 {
446 if (strpos($url, 'writeareview') === false) {
447 return str_replace('biz/', 'writeareview/biz/', $url);
448 }
449
450 return $url;
451 }
452
453 public function convert_to_tripadvisor_review_url($url)
454 {
455 if (strpos($url, 'UserReview') === false) {
456 $url_parts = explode('/', $url);
457
458 $last_url_part = end($url_parts);
459
460 $dashes_parts = explode('-', $last_url_part);
461
462 if (! empty($dashes_parts)) {
463 return str_replace($dashes_parts[0], 'UserReviewEdit', $url);
464 }
465 }
466
467 return $url;
468 }
469
470 public function get_location_url($business)
471 {
472 if (! empty($business['location_url'])) {
473 return $business['location_url'];
474 }
475 return '';
476 }
477 }
478