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 | // <img class=emoji alt=…> 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é', [...]) returns it unchanged, and |
| 48 | // esc_html('Søren') returns 'Søren', not '&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ørrebrø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ø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 |