Admin
2 months ago
Builder
2 months ago
Customizer
2 months ago
Exceptions
2 months ago
Helpers
2 months ago
Integrations
2 months ago
Migrations
2 months ago
ReviewAlerts
2 months ago
Services
2 months ago
Settings
2 months ago
Support
2 months ago
Traits
2 months ago
Utils
2 months ago
AuthorizationStatusCheck.php
2 months ago
BusinessDataCache.php
2 months ago
Clear_Cache.php
2 months ago
Container.php
2 months ago
DisplayElements.php
2 months ago
Email_Notification.php
2 months ago
Error_Reporter.php
2 months ago
Feed.php
2 months ago
FeedCache.php
2 months ago
FeedCacheUpdater.php
2 months ago
FeedDisplay.php
2 months ago
Feed_Locator.php
2 months ago
Parser.php
2 months ago
PostAggregator.php
2 months ago
RemoteRequest.php
2 months ago
SBR_Education.php
2 months ago
SBR_Settings.php
2 months ago
ServiceContainer.php
2 months ago
SinglePostCache.php
2 months ago
TemplateRenderer.php
2 months ago
Tooltip_Wizard.php
2 months ago
Util.php
2 months 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 |