PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / 2.8.0
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More v2.8.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 1 month ago Builder 1 month ago Customizer 1 month ago Exceptions 1 month ago Helpers 1 month ago Integrations 1 month ago Migrations 1 month ago ReviewAlerts 1 month ago Services 1 month ago Settings 1 month ago Support 1 month ago Traits 1 month ago Utils 1 month ago AuthorizationStatusCheck.php 1 month ago BusinessDataCache.php 1 month ago Clear_Cache.php 1 month ago Container.php 1 month ago DisplayElements.php 1 month ago Email_Notification.php 1 month ago Error_Reporter.php 1 month ago Feed.php 1 month ago FeedCache.php 1 month ago FeedCacheUpdater.php 1 month ago FeedDisplay.php 1 month ago Feed_Locator.php 1 month ago Parser.php 1 month ago PostAggregator.php 1 month ago RemoteRequest.php 1 month ago SBR_Education.php 1 month ago SBR_Settings.php 1 month ago ServiceContainer.php 1 month ago SinglePostCache.php 1 month ago TemplateRenderer.php 1 month ago Tooltip_Wizard.php 1 month ago Util.php 1 month ago
Parser.php
431 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 // Decode HTML entities (fixes Danish characters like æ, ø, å and other special chars)
25 return html_entity_decode((string) $post['text'], ENT_QUOTES | ENT_HTML5, 'UTF-8');
26 }
27 return '';
28 }
29
30
31 public function get_rating($post)
32 {
33 if (!empty($post['rating'])) {
34 if ($post['rating'] === 'positive') {
35 return 5;
36 } elseif ($post['rating'] === 'negative') {
37 return 1;
38 } else {
39 return (int) $post['rating'];
40 }
41 }
42 return 1;
43 }
44
45 public function get_time($post)
46 {
47 if (! empty($post['time'])) {
48 return $post['time'];
49 }
50 return 0;
51 }
52
53 public function get_reviewer_name($post)
54 {
55 if (! empty($post['reviewer']['name'])) {
56 // Decode HTML entities (fixes Danish characters like æ, ø, å and other special chars)
57 return html_entity_decode($post['reviewer']['name'], ENT_QUOTES | ENT_HTML5, 'UTF-8');
58 }
59 return '';
60 }
61
62
63
64 public function get_provider_name($post_or_business)
65 {
66 if (! empty($post_or_business['provider']['name'])) {
67 return $post_or_business['provider']['name'];
68 }
69 return '';
70 }
71
72 public function get_business_id($post_or_business)
73 {
74 if (! empty($post_or_business['business']['id'])) {
75 return $post_or_business['business']['id'];
76 } elseif (! empty($post_or_business['id'])) {
77 return $post_or_business['id'];
78 }
79 return '';
80 }
81
82 public function get_business_name($post_or_business)
83 {
84 if (! empty($post_or_business['business']['name'])) {
85 return $post_or_business['business']['name'];
86 } elseif (! empty($post_or_business['name']) && is_string($post_or_business['name'])) {
87 return $post_or_business['name'];
88 }
89 return '';
90 }
91
92 public function get_average_rating($businesses)
93 {
94 if (is_array($businesses)) {
95 // SMASH-1412: prefer the plugin-computed dedup'd aggregate when
96 // present. EDD / WooCommerce multi-source feeds stamp this on every
97 // source.info so the first one wins. Older feeds without the field
98 // fall through to the per-source weighted mean below.
99 foreach ($businesses as $business) {
100 if (! empty($business['info']['feed_aggregated'])) {
101 return round(floatval($business['info']['feed_average_rating'] ?? 0), 1);
102 }
103 }
104
105 // SMASH-1583: weight each source's rating by its review count so a
106 // 3-review source can't swing the headline average as hard as a
107 // 219-review one. A multi-source feed (e.g. Google 219@4.7 +
108 // Facebook 3@5.0) must report sum(rating*count)/sum(count) = 4.7,
109 // not the unweighted mean round((4.7+5.0)/2, 1) = 4.9.
110 //
111 // Falls back to the legacy unweighted mean when no source reports a
112 // usable count (manual feeds, providers without a count) — this
113 // preserves the pre-1583 number and avoids a divide-by-zero / 0.0
114 // regression on feeds whose counts are all empty.
115 $weighted_sum = 0.0;
116 $weighted_n = 0;
117 $simple_sum = 0.0;
118 $simple_n = 0;
119 foreach ($businesses as $business) {
120 // Check for rating first, then fall back to average_rating for WooCommerce multi-product sources
121 $rating = $business['info']['rating'] ?? $business['info']['average_rating'] ?? 0;
122 if (empty($rating)) {
123 continue;
124 }
125 $count = intval($business['info']['total_rating'] ?? $business['info']['review_count'] ?? 0);
126 $weighted_sum += floatval($rating) * $count;
127 $weighted_n += $count;
128 $simple_sum += floatval($rating);
129 $simple_n += 1;
130 }
131
132 if ($weighted_n > 0) {
133 return round($weighted_sum / $weighted_n, 1);
134 }
135
136 $simple_n = $simple_n === 0 ? 1 : $simple_n;
137 return round($simple_sum / $simple_n, 1);
138 }
139 return '';
140 }
141
142 /**
143 * Backfill missing per-source review counts from the actual cached reviews.
144 *
145 * Some providers don't expose a reliable count in the source header. Facebook
146 * is the canonical case (SMASH-1583): modern Pages use recommendations, so
147 * FB Graph's legacy `rating_count` comes back 0, omitted, OR a stale low
148 * value (e.g. 1 while 2 recommendations are actually cached and displayed).
149 * The header then under-reports the combined count and the count-weighted
150 * average mistreats the Facebook source.
151 *
152 * For each header source we set the count to max(reported, cached-reviews),
153 * keyed by each post's `source.id`. Sources that report a true total (Google,
154 * Yelp, Trustpilot, …) always report >= what's cached, so they're left
155 * untouched — counting cached posts would UNDER-report them. Only a stale or
156 * empty reported count (Facebook) gets corrected up to the real cached number.
157 *
158 * @param array $businesses Header data (one entry per source, each with info.id).
159 * @param array $posts Full normalized cached reviews (each with source.id).
160 * @return array Header data with info.total_rating corrected where the cached count is higher.
161 */
162 public function backfill_review_counts($businesses, $posts)
163 {
164 if (! is_array($businesses)) {
165 return [];
166 }
167 if (empty($businesses)) {
168 return $businesses; // Nothing to backfill (e.g. single-manual-review header).
169 }
170 if (empty($posts) || ! is_array($posts)) {
171 return $businesses;
172 }
173
174 $counts_by_source = $this->tally_reviews_by_source($posts);
175 if (empty($counts_by_source)) {
176 return $businesses;
177 }
178
179 foreach ($businesses as $key => $business) {
180 $info = $business['info'] ?? null;
181 if (! is_array($info)) {
182 continue;
183 }
184 $id = isset($info['id']) ? (string) $info['id'] : '';
185 if ($id === '' || empty($counts_by_source[$id])) {
186 continue;
187 }
188 // Use the LARGER of the provider-reported count and the actual cached
189 // reviews. Providers that report a true total (Google / Yelp /
190 // Trustpilot / TripAdvisor / WP.org) always report >= what's cached,
191 // so they're left untouched — their real totals routinely exceed the
192 // cached page and must never be down-counted. Facebook is the problem
193 // case: its deprecated rating_count persists 0 OR a stale low value
194 // (e.g. 1 while 2 recommendations are cached and displayed), so the
195 // cached count is the honest floor and wins.
196 $reported = (int) ($info['total_rating'] ?? $info['review_count'] ?? 0);
197 $cached = (int) $counts_by_source[$id];
198 if ($cached > $reported) {
199 $businesses[$key]['info']['total_rating'] = $cached;
200 }
201 }
202 return $businesses;
203 }
204
205 /**
206 * Tally cached reviews per source id (keyed by each post's source.id).
207 *
208 * Shared by backfill_review_counts() and the customizer-preview enrichment
209 * so the count rule lives in exactly one place.
210 *
211 * @param array $posts Normalized cached reviews.
212 * @return array<string,int> source id => review count
213 */
214 public function tally_reviews_by_source($posts)
215 {
216 $counts_by_source = [];
217 if (! is_array($posts)) {
218 return $counts_by_source;
219 }
220 foreach ($posts as $post) {
221 if (! is_array($post)) {
222 continue;
223 }
224 $source = $post['source'] ?? null;
225 if (! is_array($source)) {
226 continue;
227 }
228 $source_id = (string) ($source['id'] ?? '');
229 if ($source_id === '') {
230 continue;
231 }
232 $counts_by_source[$source_id] = ($counts_by_source[$source_id] ?? 0) + 1;
233 }
234 return $counts_by_source;
235 }
236
237 public function get_num_ratings($businesses)
238 {
239 if (is_array($businesses)) {
240 // SMASH-1412: see get_average_rating() — prefer feed-level dedup.
241 foreach ($businesses as $business) {
242 if (! empty($business['info']['feed_aggregated'])) {
243 return intval($business['info']['feed_total_review_count'] ?? 0);
244 }
245 }
246
247 $total_rating = 0;
248 foreach ($businesses as $business) {
249 // Check for total_rating first, then fall back to review_count for WooCommerce multi-product sources
250 $count = $business['info']['total_rating'] ?? $business['info']['review_count'] ?? 0;
251 if (! empty($count)) {
252 $total_rating += intval($count);
253 }
254 }
255 return $total_rating;
256 }
257 return '';
258 }
259
260 public function get_max_rating($business)
261 {
262 if (! empty($business['max'])) {
263 return $business['max'];
264 }
265 return '';
266 }
267
268 public function get_rating_type($business)
269 {
270 if (! empty($business['type'])) {
271 return $business['type'];
272 }
273 return '';
274 }
275
276 public function get_business_image($business)
277 {
278 if (! empty($business['avatar'])) {
279 return $business['avatar'];
280 }
281 return '';
282 }
283
284 public function get_review_url($business, $source)
285 {
286
287 if (! empty($business['review_url'])) {
288 return $business['review_url'];
289 }
290 if (! empty($business['info']['url'])) {
291 if (strpos($business['info']['url'], 'https://www.facebook.com') === 0) {
292 return $this->convert_to_fb_review_url($business['info']['url']);
293 } elseif (strpos($business['info']['url'], 'https://www.yelp.com') === 0) {
294 return $this->convert_to_yelp_review_url($business['info']['url']);
295 } elseif (strpos($business['info']['url'], 'https://www.tripadvisor.com') === 0) {
296 return $this->convert_to_tripadvisor_review_url($business['info']['url']);
297 } elseif (isset($source['provider']) && $source['provider'] === 'woocommerce') {
298 // WooCommerce single product - add #reviews anchor
299 return $this->convert_to_woocommerce_review_url($business['info']['url']);
300 }
301 return $business['info']['url'];
302 } else {
303 if (isset($source['provider']) && $source['provider'] === 'google') {
304 return $this->convert_to_google_review_url($source['account_id']);
305 }
306
307 // Handle WooCommerce multi-product sources - link to first product's review section
308 if (isset($source['provider']) && $source['provider'] === 'woocommerce') {
309 return $this->get_woocommerce_review_url($source);
310 }
311 }
312
313 return '';
314 }
315
316 /**
317 * Get the review URL for WooCommerce sources.
318 *
319 * For single product sources, returns the product URL with #reviews anchor.
320 * For multi-product sources, returns the first product's URL with #reviews anchor.
321 *
322 * @since 2.4.0
323 * @param array $source The source data.
324 * @return string The review URL or empty string if not available.
325 */
326 public function get_woocommerce_review_url($source)
327 {
328 // Decode info if it's a JSON string
329 $info = $source['info'] ?? [];
330 if (is_string($info)) {
331 $info = json_decode($info, true);
332 if (! is_array($info)) {
333 $info = [];
334 }
335 }
336
337 // Check for single product source URL first
338 if (! empty($info['url'])) {
339 return $this->convert_to_woocommerce_review_url($info['url']);
340 }
341
342 // For multi-product sources, use the first product's URL from direct_products (has URLs)
343 // or products array as fallback
344 if (! empty($info['direct_products']) && is_array($info['direct_products'])) {
345 $first_product = $info['direct_products'][0] ?? [];
346 if (! empty($first_product['url'])) {
347 return $this->convert_to_woocommerce_review_url($first_product['url']);
348 }
349 }
350
351 // Fallback to products array
352 if (! empty($info['products']) && is_array($info['products'])) {
353 $first_product = $info['products'][0] ?? [];
354 if (! empty($first_product['url'])) {
355 return $this->convert_to_woocommerce_review_url($first_product['url']);
356 }
357 }
358
359 // Fallback: try source URL directly
360 if (! empty($source['url'])) {
361 return $this->convert_to_woocommerce_review_url($source['url']);
362 }
363
364 return '';
365 }
366
367 /**
368 * Convert a WooCommerce product URL to a review URL by adding #reviews anchor.
369 *
370 * @since 2.4.0
371 * @param string $url The product URL.
372 * @return string The URL with #reviews anchor.
373 */
374 public function convert_to_woocommerce_review_url($url)
375 {
376 // Remove any existing fragment
377 $url = preg_replace('/#.*$/', '', $url);
378
379 // Add #reviews anchor (standard WooCommerce review tab anchor)
380 return trailingslashit($url) . '#reviews';
381 }
382
383 public function convert_to_google_review_url($account_id)
384 {
385 return "https://search.google.com/local/writereview?placeid=" . $account_id;
386 }
387
388 public function convert_to_fb_review_url($url)
389 {
390 if (strpos($url, 'reviews') === false) {
391 return trailingslashit($url) . 'reviews';
392 }
393
394 return $url;
395 }
396
397 public function convert_to_yelp_review_url($url)
398 {
399 if (strpos($url, 'writeareview') === false) {
400 return str_replace('biz/', 'writeareview/biz/', $url);
401 }
402
403 return $url;
404 }
405
406 public function convert_to_tripadvisor_review_url($url)
407 {
408 if (strpos($url, 'UserReview') === false) {
409 $url_parts = explode('/', $url);
410
411 $last_url_part = end($url_parts);
412
413 $dashes_parts = explode('-', $last_url_part);
414
415 if (! empty($dashes_parts)) {
416 return str_replace($dashes_parts[0], 'UserReviewEdit', $url);
417 }
418 }
419
420 return $url;
421 }
422
423 public function get_location_url($business)
424 {
425 if (! empty($business['location_url'])) {
426 return $business['location_url'];
427 }
428 return '';
429 }
430 }
431