PluginProbe ʕ •ᴥ•ʔ
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents / 4.6.0
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents v4.6.0
4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 trunk 1.0.0 1.1.0 1.1.1 1.1.2 1.1.3 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.5.0 1.6.0 1.6.1 1.6.2 1.6.3 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.7.5 2.0.0 2.0.1 2.0.2 2.0.3 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.2.0 2.2.1 2.2.2 2.3.0 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.5.0 2.5.1 2.5.2 2.5.3 2.5.4 2.5.5 2.6.0 2.6.1 2.6.2 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.1.0 3.1.1 3.1.2 3.1.3 3.2.0 3.2.1 3.3.0 3.3.1 3.3.2 3.3.3 3.3.4 3.3.5 3.3.6 3.3.7 3.4.0 3.4.1 3.4.2 3.4.3 3.5.0 3.5.1 3.5.2 3.5.3 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.6.8 3.7.0 3.7.1 3.7.2 3.7.3 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.9.0 3.9.1 3.9.10 3.9.11 3.9.12 3.9.13 3.9.14 3.9.15 3.9.16 3.9.17 3.9.2 3.9.3 3.9.4 3.9.5 3.9.6 3.9.7 3.9.8 3.9.9 4.0.0 4.0.1 4.0.10 4.0.11 4.0.12 4.0.13 4.0.14 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.10 4.1.2 4.1.3 4.1.4 4.1.5 4.1.6 4.1.7 4.1.8 4.1.9 4.2.0 4.2.1 4.2.2 4.2.3 4.2.4 4.2.5 4.2.6 4.2.7 4.2.8 4.2.9 4.3.0 4.3.1 4.4.0 4.4.1 4.4.10 4.4.11 4.4.2 4.4.3 4.4.4 4.4.5 4.4.6 4.4.7 4.4.8 4.4.9 4.5.0 4.5.1
embedpress / EmbedPress / Includes / Classes / GoogleReviewsApify.php
embedpress / EmbedPress / Includes / Classes Last commit date
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