Analytics
2 weeks ago
Database
2 months ago
DynamicFieldResolver.php
1 month ago
Elementor_Enhancer.php
6 months ago
EmbedPress_Core_Installer.php
6 years ago
EmbedPress_Notice.php
3 months ago
EmbedPress_Plugin_Usage_Tracker.php
1 month ago
Extend_CustomPlayer_Controls.php
2 months ago
Extend_Elementor_Controls.php
1 year ago
FeatureNoticeManager.php
4 days ago
FeatureNotices.php
4 days ago
FeaturePreviewModal.php
4 days ago
Feature_Enhancer.php
1 month ago
GoogleReviewsAdminPage.php
4 days ago
GoogleReviewsApify.php
4 days ago
GoogleReviewsManaged.php
4 days ago
GoogleReviewsRenderer.php
4 days ago
GoogleReviewsRestController.php
4 days ago
GoogleReviewsStore.php
4 days ago
Helper.php
4 days ago
Pdf_Thumbnail_Handler.php
2 months ago
PermalinkHelper.php
10 months ago
View_Count_Display.php
2 weeks ago
GoogleReviewsApify.php
502 lines
| 1 | <?php |
| 2 | |
| 3 | namespace EmbedPress\Includes\Classes; |
| 4 | |
| 5 | if (!defined('ABSPATH')) { |
| 6 | exit; |
| 7 | } |
| 8 | |
| 9 | /** |
| 10 | * Background, batched Apify fetch for "Fetch all reviews". |
| 11 | * |
| 12 | * Fetching all reviews for a place can return hundreds of items — too slow for |
| 13 | * one synchronous request (Apify run-sync hard-caps at 300s). So instead: |
| 14 | * |
| 15 | * 1. start_job(): POST an ASYNC Apify run (returns immediately with a run id), |
| 16 | * mark the place fetch_status=running. |
| 17 | * 2. WP-cron (ep_gr_poll_apify) polls the run; once SUCCEEDED, it pages the |
| 18 | * dataset in BATCHES (PAGE_SIZE at a time), appending each batch to the DB |
| 19 | * store and updating fetched_so_far, then reschedules itself until the |
| 20 | * whole dataset is imported. status→done. |
| 21 | * |
| 22 | * The admin UI shows live progress ("fetching… N so far"); the block always |
| 23 | * renders whatever is already stored (never blocked on the fetch). |
| 24 | * |
| 25 | * Moved from embedpress-pro/includes/Filters/Google_Reviews_Apify.php to free |
| 26 | * so refetch works without Pro. Pro extends behaviour via filters (multi-place |
| 27 | * merge, advanced layouts, filtering, theme, schema) — see |
| 28 | * embedpress-pro/includes/Filters/Google_Reviews_Pro.php. |
| 29 | */ |
| 30 | class GoogleReviewsApify |
| 31 | { |
| 32 | const ACTOR = 'compass~google-maps-reviews-scraper'; |
| 33 | const CRON_HOOK = 'ep_gr_poll_apify'; |
| 34 | const PAGE_SIZE = 100; // reviews imported per cron tick |
| 35 | const MAX_TOTAL = 1000; // safety ceiling per place |
| 36 | |
| 37 | public function __construct() |
| 38 | { |
| 39 | add_action(self::CRON_HOOK, [$this, 'poll_job'], 10, 1); |
| 40 | // Default behaviour for the renderer's extension points. Pro overrides |
| 41 | // these with higher-priority callbacks to layer in multi-place merge, |
| 42 | // unlimited fetch caps, etc. |
| 43 | add_filter('embedpress/google_reviews/start_fetch_job', [$this, 'start_fetch_job_filter'], 10, 3); |
| 44 | add_filter('embedpress/google_reviews/pre_fetch', [$this, 'pre_fetch_filter'], 10, 3); |
| 45 | } |
| 46 | |
| 47 | public static function api_base(): string |
| 48 | { |
| 49 | return 'https://api.apify.com/v2'; |
| 50 | } |
| 51 | |
| 52 | /** |
| 53 | * Filter callback for the renderer's "start_fetch_job" hook (used by the |
| 54 | * refetch REST action). Returns true if the background job started so the |
| 55 | * caller skips the sync ≤5 fallback. |
| 56 | */ |
| 57 | public function start_fetch_job_filter($started, $place_id, $args) |
| 58 | { |
| 59 | if ($started) { |
| 60 | return $started; // another handler already started one |
| 61 | } |
| 62 | if (GoogleReviewsRenderer::get_apify_token() === '') { |
| 63 | return false; // no token → let the caller do the sync ≤5 fallback |
| 64 | } |
| 65 | return self::start_job((string) $place_id, is_array($args) ? $args : []); |
| 66 | } |
| 67 | |
| 68 | /** |
| 69 | * Filter callback for the renderer's "pre_fetch" hook. Single-place Apify |
| 70 | * sync fetch when "fetch all" is on and a token is set. Multi-place merge |
| 71 | * is layered on by Pro at a higher priority. |
| 72 | * |
| 73 | * Returns null to fall through to the official ≤5 API path. |
| 74 | */ |
| 75 | public function pre_fetch_filter($pre, $place_id, $args) |
| 76 | { |
| 77 | if (is_array($pre)) { |
| 78 | return $pre; // another handler already produced a result |
| 79 | } |
| 80 | $fetch_all = !empty($args['fetch_all']) && GoogleReviewsRenderer::get_apify_token() !== ''; |
| 81 | if (!$fetch_all) { |
| 82 | return null; |
| 83 | } |
| 84 | $fetch_max = isset($args['fetch_max']) && (int) $args['fetch_max'] > 0 |
| 85 | ? (int) $args['fetch_max'] |
| 86 | : 0; // 0 = "all" (provider applies its own ceiling) |
| 87 | $result = self::fetch_via_apify_sync($place_id, $fetch_max, is_array($args) ? $args : []); |
| 88 | return is_array($result) ? $result : null; |
| 89 | } |
| 90 | |
| 91 | /** |
| 92 | * Kick off an async Apify run for a place and mark the job running. |
| 93 | * Returns true if the run started, false (with a stored error) otherwise. |
| 94 | */ |
| 95 | public static function start_job(string $place_id, array $args = []): bool |
| 96 | { |
| 97 | $token = GoogleReviewsRenderer::get_apify_token(); |
| 98 | if ($token === '' || $place_id === '') { |
| 99 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => __('No Apify token configured.', 'embedpress')]); |
| 100 | return false; |
| 101 | } |
| 102 | |
| 103 | // Two refetch modes: |
| 104 | // - incremental (default): preserve the existing review set, ask Apify |
| 105 | // for a small newest-first window, stop importing the moment we hit |
| 106 | // a fingerprint already in the DB. Cheap, picks up new reviews |
| 107 | // without re-fetching the whole list. |
| 108 | // - full: wipe and re-pull. The user-explicit "give me a fresh copy" |
| 109 | // mode — surfaces edits/deletes, costs more. |
| 110 | $incremental = !empty($args['incremental']); |
| 111 | |
| 112 | // Ceiling is the hard safety cap; the user's chosen count (fetch_max) |
| 113 | // lowers it so they control how many reviews — and thus how much Apify |
| 114 | // cost — each refetch incurs. fetch_max = 0 (or unset) means "all up to |
| 115 | // the ceiling". |
| 116 | $ceiling = (int) apply_filters('embedpress/google_reviews/apify_ceiling', self::MAX_TOTAL, $place_id, $args); |
| 117 | $fetch_max = isset($args['fetch_max']) ? (int) $args['fetch_max'] : 0; |
| 118 | |
| 119 | if ($incremental && $fetch_max <= 0) { |
| 120 | // Size the incremental window to actual need using Google's |
| 121 | // reported total (cached in meta from a previous fetch): |
| 122 | // expected_new = google_total - already_in_db |
| 123 | // Add a 20% buffer for last-minute reviews + cap at the ceiling |
| 124 | // so a brand-new place with 50,000 reviews doesn't accidentally |
| 125 | // run a full scrape on a quick check. |
| 126 | $row = GoogleReviewsStore::get($place_id); |
| 127 | $meta_total = 0; |
| 128 | $db_count = 0; |
| 129 | if ($row) { |
| 130 | $meta_total = isset($row['meta']['total']) ? (int) $row['meta']['total'] : 0; |
| 131 | $db_count = isset($row['review_count']) ? (int) $row['review_count'] : 0; |
| 132 | } |
| 133 | $expected_new = max(0, $meta_total - $db_count); |
| 134 | $window_floor = (int) apply_filters('embedpress/google_reviews/apify_incremental_floor', 50, $place_id, $args); |
| 135 | $window_ceil = (int) apply_filters('embedpress/google_reviews/apify_incremental_ceiling', 500, $place_id, $args); |
| 136 | $fetch_max = max($window_floor, min($window_ceil, (int) ceil($expected_new * 1.2) + 5)); |
| 137 | } |
| 138 | $max = ($fetch_max > 0) ? min($fetch_max, $ceiling) : $ceiling; |
| 139 | |
| 140 | $payload = [ |
| 141 | 'startUrls' => [['url' => 'https://www.google.com/maps/place/?q=place_id:' . $place_id]], |
| 142 | 'placeIds' => [$place_id], |
| 143 | 'maxReviews' => $max, |
| 144 | 'language' => 'en', |
| 145 | 'reviewsSort' => self::sort_param($args), |
| 146 | ]; |
| 147 | |
| 148 | // Pin memory so the run fits Apify's free 8192MB plan. The reviews-scraper |
| 149 | // defaults to 1024MB; we keep that explicit (filterable for paid plans |
| 150 | // that want more for faster/bigger fetches). Without pinning, an actor |
| 151 | // default bump or an overlapping run can trip "you will exceed the memory |
| 152 | // limit". See embedpress/google_reviews/apify_search_memory (search side). |
| 153 | $memory = (int) apply_filters('embedpress/google_reviews/apify_fetch_memory', 1024, $place_id, $args); |
| 154 | $endpoint = self::api_base() . '/acts/' . self::ACTOR . '/runs?token=' . rawurlencode($token); |
| 155 | if ($memory > 0) { |
| 156 | $endpoint .= '&memory=' . $memory; |
| 157 | } |
| 158 | $response = wp_remote_post($endpoint, [ |
| 159 | 'timeout' => 20, |
| 160 | 'headers' => ['Content-Type' => 'application/json'], |
| 161 | 'body' => wp_json_encode($payload), |
| 162 | ]); |
| 163 | |
| 164 | if (is_wp_error($response)) { |
| 165 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => $response->get_error_message()]); |
| 166 | return false; |
| 167 | } |
| 168 | $code = (int) wp_remote_retrieve_response_code($response); |
| 169 | $body = json_decode(wp_remote_retrieve_body($response), true); |
| 170 | if (($code !== 200 && $code !== 201) || empty($body['data']['id'])) { |
| 171 | $msg = $body['error']['message'] ?? sprintf(__('Apify returned HTTP %d.', 'embedpress'), $code); |
| 172 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => $msg]); |
| 173 | return false; |
| 174 | } |
| 175 | |
| 176 | // FULL mode: clear the stored set so this refresh REPLACES the reviews |
| 177 | // (batches append as they import; without the reset they'd stack on top |
| 178 | // of the previous fetch and inflate the count). |
| 179 | // INCREMENTAL mode: keep the existing set; new reviews append via the |
| 180 | // store's dedup; cron stops on first known-fingerprint hit. |
| 181 | if (!$incremental) { |
| 182 | GoogleReviewsStore::reset_reviews($place_id); |
| 183 | } |
| 184 | |
| 185 | // Persist the mode so poll_job (running in a different request) knows |
| 186 | // whether to apply early-stop fingerprint matching. Transient TTL bounds |
| 187 | // any leak if the job dies — ample for a typical job that finishes in |
| 188 | // under 5 minutes. |
| 189 | set_transient(self::mode_transient_key($place_id), $incremental ? 'incremental' : 'full', 30 * MINUTE_IN_SECONDS); |
| 190 | |
| 191 | $run_id = (string) $body['data']['id']; |
| 192 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_RUNNING, [ |
| 193 | 'run_id' => $run_id, |
| 194 | 'message' => $incremental ? __('Checking for new reviews…', 'embedpress') : __('Starting…', 'embedpress'), |
| 195 | // Preserve fetched_so_far in incremental mode — it's a running count |
| 196 | // of reviews already in the DB, not a job-local counter. |
| 197 | ]); |
| 198 | |
| 199 | self::schedule_poll($place_id, 15); |
| 200 | return true; |
| 201 | } |
| 202 | |
| 203 | private static function mode_transient_key(string $place_id): string |
| 204 | { |
| 205 | return 'embedpress_gr_job_mode_' . md5($place_id); |
| 206 | } |
| 207 | |
| 208 | public static function get_job_mode(string $place_id): string |
| 209 | { |
| 210 | return (string) (get_transient(self::mode_transient_key($place_id)) ?: 'full'); |
| 211 | } |
| 212 | |
| 213 | public static function clear_job_mode(string $place_id): void |
| 214 | { |
| 215 | delete_transient(self::mode_transient_key($place_id)); |
| 216 | } |
| 217 | |
| 218 | /** Schedule a one-off poll for a place after $delay seconds. */ |
| 219 | public static function schedule_poll(string $place_id, int $delay = 30): void |
| 220 | { |
| 221 | if (!wp_next_scheduled(self::CRON_HOOK, [$place_id])) { |
| 222 | wp_schedule_single_event(time() + max(5, $delay), self::CRON_HOOK, [$place_id]); |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | /** |
| 227 | * Cron callback. Check the run; when finished, import one batch of dataset |
| 228 | * items and reschedule until the whole dataset is in the DB. |
| 229 | */ |
| 230 | public function poll_job($place_id) |
| 231 | { |
| 232 | $place_id = (string) $place_id; |
| 233 | $row = GoogleReviewsStore::get($place_id); |
| 234 | if (!$row || ($row['fetch_status'] ?? '') !== GoogleReviewsStore::STATUS_RUNNING) { |
| 235 | return; // cancelled / already done |
| 236 | } |
| 237 | $token = GoogleReviewsRenderer::get_apify_token(); |
| 238 | $run_id = (string) ($row['fetch_run_id'] ?? ''); |
| 239 | if ($token === '' || $run_id === '') { |
| 240 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => __('Lost run reference.', 'embedpress')]); |
| 241 | self::clear_job_mode($place_id); |
| 242 | return; |
| 243 | } |
| 244 | |
| 245 | // 1) Check run status. |
| 246 | $run = self::get_json(self::api_base() . '/actor-runs/' . rawurlencode($run_id) . '?token=' . rawurlencode($token)); |
| 247 | $status = $run['data']['status'] ?? 'UNKNOWN'; |
| 248 | |
| 249 | if (in_array($status, ['READY', 'RUNNING'], true)) { |
| 250 | // still working — check again shortly |
| 251 | self::schedule_poll($place_id, 20); |
| 252 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_RUNNING, ['message' => __('Fetching from Google…', 'embedpress')]); |
| 253 | return; |
| 254 | } |
| 255 | if ($status !== 'SUCCEEDED') { |
| 256 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => sprintf(__('Apify run %s.', 'embedpress'), strtolower($status))]); |
| 257 | self::clear_job_mode($place_id); |
| 258 | return; |
| 259 | } |
| 260 | |
| 261 | $dataset_id = $run['data']['defaultDatasetId'] ?? ''; |
| 262 | if ($dataset_id === '') { |
| 263 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => __('No dataset produced.', 'embedpress')]); |
| 264 | self::clear_job_mode($place_id); |
| 265 | return; |
| 266 | } |
| 267 | |
| 268 | // 2) Import one batch. The DATASET OFFSET (where we are inside Apify's |
| 269 | // result set) differs from fetched_so_far in incremental mode: |
| 270 | // - full mode: store was wiped at start_job, so fetched_so_far == dataset offset. |
| 271 | // - incremental: store was preserved, so fetched_so_far is the existing |
| 272 | // review count, NOT a scan position. Use a transient counter instead. |
| 273 | $mode = self::get_job_mode($place_id); |
| 274 | $incremental = ($mode === 'incremental'); |
| 275 | |
| 276 | if ($incremental) { |
| 277 | $offset_key = 'embedpress_gr_job_offset_' . md5($place_id); |
| 278 | $offset = (int) (get_transient($offset_key) ?: 0); |
| 279 | } else { |
| 280 | $offset = (int) ($row['fetched_so_far'] ?? 0); |
| 281 | } |
| 282 | |
| 283 | $items = self::get_json( |
| 284 | self::api_base() . '/datasets/' . rawurlencode($dataset_id) . '/items' |
| 285 | . '?clean=true&format=json&offset=' . $offset . '&limit=' . self::PAGE_SIZE |
| 286 | . '&token=' . rawurlencode($token) |
| 287 | ); |
| 288 | |
| 289 | if (!is_array($items) || empty($items)) { |
| 290 | // Nothing more → finalize. |
| 291 | GoogleReviewsStore::append_reviews($place_id, [], [], 'apify', true); |
| 292 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_DONE, ['message' => null, 'run_id' => null]); |
| 293 | self::clear_job_mode($place_id); |
| 294 | if ($incremental) delete_transient('embedpress_gr_job_offset_' . md5($place_id)); |
| 295 | return; |
| 296 | } |
| 297 | |
| 298 | $mapped = self::map_items($items); |
| 299 | $meta = !empty($row['meta']) && empty($row['meta']['name']) === false ? $row['meta'] : $mapped['meta']; |
| 300 | |
| 301 | // Incremental: walk batch newest-first; the FIRST review we already |
| 302 | // have means we've caught up — everything past it is already stored. |
| 303 | // Apify returns reviews newest-first by default, so this works. |
| 304 | $caught_up = false; |
| 305 | if ($incremental) { |
| 306 | $known = GoogleReviewsStore::fingerprints_for($place_id); |
| 307 | $new_in_batch = []; |
| 308 | foreach ($mapped['reviews'] as $r) { |
| 309 | $fp = GoogleReviewsStore::fingerprint_of($r); |
| 310 | if (isset($known[$fp])) { |
| 311 | $caught_up = true; |
| 312 | break; |
| 313 | } |
| 314 | $new_in_batch[] = $r; |
| 315 | } |
| 316 | $mapped['reviews'] = $new_in_batch; |
| 317 | } |
| 318 | |
| 319 | // Append this batch; final if we caught up (incremental), got a short |
| 320 | // page, or hit the safety ceiling. |
| 321 | $reached_end = $caught_up |
| 322 | || count($items) < self::PAGE_SIZE |
| 323 | || ($offset + count($items)) >= self::MAX_TOTAL; |
| 324 | GoogleReviewsStore::append_reviews($place_id, $mapped['reviews'], $meta, 'apify', $reached_end); |
| 325 | |
| 326 | if ($reached_end) { |
| 327 | $new_count = count($mapped['reviews'] ?? []); |
| 328 | // Surface a "may have missed more" hint when incremental finishes |
| 329 | // without finding a known fingerprint — likely the window was too |
| 330 | // small. Encourages the user to do a Full refresh. |
| 331 | $message = null; |
| 332 | if ($incremental) { |
| 333 | if ($new_count === 0) { |
| 334 | $message = __('Already up to date.', 'embedpress'); |
| 335 | } elseif (!$caught_up) { |
| 336 | $message = sprintf( |
| 337 | /* translators: %d is the number of newly added reviews */ |
| 338 | _n( |
| 339 | 'Added %d new review. There may be more — run a Full refresh to be sure.', |
| 340 | 'Added %d new reviews. There may be more — run a Full refresh to be sure.', |
| 341 | $new_count, |
| 342 | 'embedpress' |
| 343 | ), |
| 344 | $new_count |
| 345 | ); |
| 346 | } else { |
| 347 | $message = sprintf( |
| 348 | _n('Added %d new review.', 'Added %d new reviews.', $new_count, 'embedpress'), |
| 349 | $new_count |
| 350 | ); |
| 351 | } |
| 352 | } |
| 353 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_DONE, [ |
| 354 | 'message' => $message, |
| 355 | 'run_id' => null, |
| 356 | ]); |
| 357 | self::clear_job_mode($place_id); |
| 358 | if ($incremental) delete_transient('embedpress_gr_job_offset_' . md5($place_id)); |
| 359 | } else { |
| 360 | if ($incremental) { |
| 361 | set_transient('embedpress_gr_job_offset_' . md5($place_id), $offset + count($items), 30 * MINUTE_IN_SECONDS); |
| 362 | } |
| 363 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_RUNNING, [ |
| 364 | 'message' => $incremental |
| 365 | ? __('Checking for new reviews…', 'embedpress') |
| 366 | : sprintf(__('Imported %d…', 'embedpress'), $offset + count($items)), |
| 367 | ]); |
| 368 | self::schedule_poll($place_id, 8); |
| 369 | } |
| 370 | } |
| 371 | |
| 372 | /** |
| 373 | * Synchronous single-place Apify fetch (used by pre_fetch_filter for the |
| 374 | * non-background path). Bounded by $limit + the actor ceiling. Returns |
| 375 | * {reviews, meta} or null on any failure (caller falls back to API). |
| 376 | * |
| 377 | * @return array|null {reviews, meta} |
| 378 | */ |
| 379 | public static function fetch_via_apify_sync(string $place_id, int $limit, array $args = []) |
| 380 | { |
| 381 | $token = GoogleReviewsRenderer::get_apify_token(); |
| 382 | if ($token === '' || $place_id === '') { |
| 383 | return null; |
| 384 | } |
| 385 | $ceiling = (int) apply_filters('embedpress/google_reviews/apify_ceiling', 200, $place_id, $args); |
| 386 | $max = ($limit <= 0) ? $ceiling : min($limit, $ceiling); |
| 387 | |
| 388 | $memory = (int) apply_filters('embedpress/google_reviews/apify_fetch_memory', 1024, $place_id, $args); |
| 389 | $endpoint = self::api_base() . '/acts/' . self::ACTOR . '/run-sync-get-dataset-items?token=' . rawurlencode($token); |
| 390 | if ($memory > 0) { |
| 391 | $endpoint .= '&memory=' . $memory; |
| 392 | } |
| 393 | $payload = [ |
| 394 | 'startUrls' => [['url' => 'https://www.google.com/maps/place/?q=place_id:' . $place_id]], |
| 395 | 'placeIds' => [$place_id], |
| 396 | 'maxReviews' => $max, |
| 397 | 'language' => 'en', |
| 398 | 'reviewsSort' => self::sort_param($args), |
| 399 | ]; |
| 400 | |
| 401 | $response = wp_remote_post($endpoint, [ |
| 402 | 'timeout' => 120, // generous; the actor itself is capped at 300s server-side |
| 403 | 'headers' => ['Content-Type' => 'application/json'], |
| 404 | 'body' => wp_json_encode($payload), |
| 405 | ]); |
| 406 | if (is_wp_error($response)) { |
| 407 | return null; |
| 408 | } |
| 409 | $code = (int) wp_remote_retrieve_response_code($response); |
| 410 | if ($code !== 200 && $code !== 201) { |
| 411 | return null; |
| 412 | } |
| 413 | $items = json_decode(wp_remote_retrieve_body($response), true); |
| 414 | if (!is_array($items) || empty($items)) { |
| 415 | return null; |
| 416 | } |
| 417 | $mapped = self::map_items($items); |
| 418 | return empty($mapped['reviews']) ? null : $mapped; |
| 419 | } |
| 420 | |
| 421 | /* ── helpers ──────────────────────────────────────────────────────── */ |
| 422 | |
| 423 | private static function get_json(string $url) |
| 424 | { |
| 425 | $res = wp_remote_get($url, ['timeout' => 30, 'headers' => ['Accept' => 'application/json']]); |
| 426 | if (is_wp_error($res)) { |
| 427 | return null; |
| 428 | } |
| 429 | return json_decode(wp_remote_retrieve_body($res), true); |
| 430 | } |
| 431 | |
| 432 | private static function sort_param(array $args): string |
| 433 | { |
| 434 | switch (isset($args['sort']) ? (string) $args['sort'] : 'newest') { |
| 435 | case 'highest': return 'highestRanking'; |
| 436 | case 'lowest': return 'lowestRanking'; |
| 437 | case 'relevant': return 'mostRelevant'; |
| 438 | default: return 'newest'; |
| 439 | } |
| 440 | } |
| 441 | |
| 442 | /** |
| 443 | * Map Apify dataset items → {reviews, meta}, same shape EmbedPress uses. |
| 444 | */ |
| 445 | public static function map_items(array $items): array |
| 446 | { |
| 447 | $reviews = []; |
| 448 | $meta = []; |
| 449 | foreach ($items as $it) { |
| 450 | if (!is_array($it)) { |
| 451 | continue; |
| 452 | } |
| 453 | if (empty($meta)) { |
| 454 | $meta = [ |
| 455 | 'name' => (string) ($it['title'] ?? $it['placeName'] ?? ''), |
| 456 | 'rating' => isset($it['totalScore']) ? (float) $it['totalScore'] : 0.0, |
| 457 | 'total' => isset($it['reviewsCount']) ? (int) $it['reviewsCount'] : 0, |
| 458 | 'address' => (string) ($it['address'] ?? ''), |
| 459 | ]; |
| 460 | } |
| 461 | $stars = isset($it['stars']) ? (int) $it['stars'] : 0; |
| 462 | if ($stars < 1) { |
| 463 | continue; |
| 464 | } |
| 465 | $time = 0; |
| 466 | if (!empty($it['publishedAtDate'])) { |
| 467 | $t = strtotime((string) $it['publishedAtDate']); |
| 468 | $time = $t ? $t : 0; |
| 469 | } |
| 470 | // Rich fields (Apify-only — Google's official API never returns |
| 471 | // these). Each is optional; the renderer renders them only when |
| 472 | // present, so API-sourced reviews simply omit them. |
| 473 | $images = []; |
| 474 | if (!empty($it['reviewImageUrls']) && is_array($it['reviewImageUrls'])) { |
| 475 | foreach ($it['reviewImageUrls'] as $img) { |
| 476 | $u = esc_url_raw((string) $img); |
| 477 | if ($u !== '') { |
| 478 | $images[] = $u; |
| 479 | } |
| 480 | } |
| 481 | } |
| 482 | |
| 483 | $reviews[] = [ |
| 484 | 'author_name' => (string) ($it['name'] ?? __('Google user', 'embedpress')), |
| 485 | 'rating' => $stars, |
| 486 | 'text' => (string) ($it['text'] ?? $it['textTranslated'] ?? ''), |
| 487 | 'time' => $time, |
| 488 | 'relative_time' => (string) ($it['publishAt'] ?? ''), |
| 489 | 'profile_photo_url' => !empty($it['reviewerPhotoUrl']) ? esc_url_raw((string) $it['reviewerPhotoUrl']) : '', |
| 490 | // Rich (Apify): |
| 491 | 'is_local_guide' => !empty($it['isLocalGuide']), |
| 492 | 'reviewer_reviews' => isset($it['reviewerNumberOfReviews']) ? (int) $it['reviewerNumberOfReviews'] : 0, |
| 493 | 'likes' => isset($it['likesCount']) ? (int) $it['likesCount'] : 0, |
| 494 | 'images' => $images, |
| 495 | 'owner_response' => (string) ($it['responseFromOwnerText'] ?? ''), |
| 496 | 'owner_response_time' => (string) ($it['responseFromOwnerDate'] ?? ''), |
| 497 | ]; |
| 498 | } |
| 499 | return ['reviews' => $reviews, 'meta' => $meta]; |
| 500 | } |
| 501 | } |
| 502 |