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
GoogleReviewsManaged.php
661 lines
| 1 | <?php |
| 2 | |
| 3 | namespace EmbedPress\Includes\Classes; |
| 4 | |
| 5 | if (!defined('ABSPATH')) { |
| 6 | exit; |
| 7 | } |
| 8 | |
| 9 | /** |
| 10 | * Hosted-proxy review fetch via api.embedpress.com/google-reviews/v1. |
| 11 | * |
| 12 | * Lets EmbedPress installs fetch reviews with ZERO user setup — search runs |
| 13 | * through the existing managed_search endpoint (`google-places.php`), and |
| 14 | * now the review fetch runs through the scraping worker at |
| 15 | * `api.embedpress.com/google-reviews/v1`. The site never sees an API key, |
| 16 | * never connects an Apify token; the proxy holds both. |
| 17 | * |
| 18 | * Architecture mirrors GoogleReviewsApify on purpose so the two are |
| 19 | * interchangeable from the renderer's POV: |
| 20 | * |
| 21 | * start_job($place_id, $args) |
| 22 | * → POSTs to enqueue.php with the place_id + max |
| 23 | * → on cache hit (24h): writes reviews directly to the store, done |
| 24 | * → on queued: stores the job_id in fetch_run_id, schedules a WP-cron poll |
| 25 | * |
| 26 | * poll_job($place_id) |
| 27 | * → GETs status.php?job_id=… |
| 28 | * → "running" → reschedule |
| 29 | * → "done" → write reviews to the store, status=done |
| 30 | * → "failed" → set the failed status with the proxy's message |
| 31 | * |
| 32 | * This class is the LOWEST-priority handler on the `start_fetch_job` and |
| 33 | * `pre_fetch` filters — Apify (user's token) wins when connected; managed |
| 34 | * runs as the fallback when no user keys exist. |
| 35 | */ |
| 36 | class GoogleReviewsManaged |
| 37 | { |
| 38 | const CRON_HOOK = 'ep_gr_poll_managed'; |
| 39 | |
| 40 | // Where the proxy lives. Filterable so dev/staging environments can |
| 41 | // point at a local instance. |
| 42 | const DEFAULT_ENDPOINT = 'https://api.embedpress.com/google-reviews/v1'; |
| 43 | |
| 44 | // wp_options key holding the Bearer token + binding metadata returned |
| 45 | // by api.embedpress.com/google-reviews/v1/connect.php. Shape: |
| 46 | // [ |
| 47 | // 'token' => 'epgr_...', |
| 48 | // 'site_id' => '<uuid>', |
| 49 | // 'home_url' => 'https://example.com', |
| 50 | // 'fingerprint' => '<sha256 hex>', |
| 51 | // 'tier' => 'free' | 'pro', |
| 52 | // 'connected_at' => 1781700000, |
| 53 | // ] |
| 54 | // Stored with autoload=no so it doesn't bloat every wp_load_alloptions(). |
| 55 | const OPT_AUTH = 'embedpress_google_reviews_managed_auth'; |
| 56 | |
| 57 | public function __construct() |
| 58 | { |
| 59 | add_action(self::CRON_HOOK, [$this, 'poll_job'], 10, 1); |
| 60 | // Priority 20 = LOWER than Apify (10) and Pro overrides. Apify-with- |
| 61 | // token takes precedence; managed kicks in only when nothing else |
| 62 | // produces a result. |
| 63 | add_filter('embedpress/google_reviews/start_fetch_job', [$this, 'start_fetch_job_filter'], 20, 3); |
| 64 | add_filter('embedpress/google_reviews/pre_fetch', [$this, 'pre_fetch_filter'], 20, 3); |
| 65 | } |
| 66 | |
| 67 | public static function endpoint(): string |
| 68 | { |
| 69 | return (string) apply_filters( |
| 70 | 'embedpress/google_reviews/managed_endpoint', |
| 71 | self::DEFAULT_ENDPOINT |
| 72 | ); |
| 73 | } |
| 74 | |
| 75 | /** |
| 76 | * Sibling endpoint: api.embedpress.com/google-places.php hosts the |
| 77 | * INSTANT review-fetch path (Places Details with reviews) — up to 5 |
| 78 | * reviews per place returned in ~300ms, no Chrome involved. Reuses the |
| 79 | * same Bearer token issued by connect.php. |
| 80 | * |
| 81 | * We derive the URL from `endpoint()` so a dev-override (filter pointing |
| 82 | * at staging) keeps both endpoints in sync. |
| 83 | */ |
| 84 | public static function instant_endpoint(): string |
| 85 | { |
| 86 | $base = rtrim(self::endpoint(), '/'); |
| 87 | // endpoint() points at /google-reviews/v1 — pop two segments to get |
| 88 | // the host root, then append /google-places.php. |
| 89 | $root = preg_replace('#/google-reviews/v1$#', '', $base); |
| 90 | $root = rtrim($root, '/'); |
| 91 | return (string) apply_filters( |
| 92 | 'embedpress/google_reviews/managed_instant_endpoint', |
| 93 | $root . '/google-places.php' |
| 94 | ); |
| 95 | } |
| 96 | |
| 97 | /** |
| 98 | * Synchronous, instant 5-review fetch via Google Places API. |
| 99 | * |
| 100 | * Returns: |
| 101 | * ['ok' => true, 'reviews' => [...], 'meta' => [...]] on success |
| 102 | * ['ok' => false, 'message' => string] on failure |
| 103 | * |
| 104 | * Used by the add-place handler so the user sees real reviews on the |
| 105 | * place card the moment they add it — no spinner, no background job. |
| 106 | * Chrome-based scraping is reserved for "Fetch all" when the user |
| 107 | * explicitly wants more than the 5 Google's Place Details returns. |
| 108 | */ |
| 109 | public static function fetch_instant(string $place_id): array |
| 110 | { |
| 111 | if ($place_id === '') { |
| 112 | return ['ok' => false, 'message' => __('Missing place ID.', 'embedpress')]; |
| 113 | } |
| 114 | if (!self::is_connected()) { |
| 115 | return ['ok' => false, 'message' => __('Not connected to EmbedPress API.', 'embedpress')]; |
| 116 | } |
| 117 | |
| 118 | $url = add_query_arg( |
| 119 | [ |
| 120 | 'action' => 'reviews', |
| 121 | 'place_id' => $place_id, |
| 122 | ], |
| 123 | self::instant_endpoint() |
| 124 | ); |
| 125 | |
| 126 | $response = wp_remote_get($url, [ |
| 127 | 'timeout' => (int) apply_filters('embedpress/google_reviews/instant_timeout', 8, $place_id), |
| 128 | 'headers' => self::auth_headers(), |
| 129 | ]); |
| 130 | if (is_wp_error($response)) { |
| 131 | return ['ok' => false, 'message' => $response->get_error_message()]; |
| 132 | } |
| 133 | $code = (int) wp_remote_retrieve_response_code($response); |
| 134 | $body = json_decode((string) wp_remote_retrieve_body($response), true); |
| 135 | |
| 136 | // Auth failure: surface as a reconnect prompt, mirroring start_job. |
| 137 | $auth_msg = self::auth_failure_message($code, $body); |
| 138 | if ($auth_msg !== '') { |
| 139 | if ($code === 401 && is_array($body) && in_array(($body['error'] ?? ''), ['invalid_token', 'missing_token'], true)) { |
| 140 | self::disconnect(); |
| 141 | } |
| 142 | return ['ok' => false, 'message' => $auth_msg]; |
| 143 | } |
| 144 | |
| 145 | if ($code !== 200 || !is_array($body)) { |
| 146 | $msg = is_array($body) && !empty($body['message']) |
| 147 | ? (string) $body['message'] |
| 148 | : __('Couldn’t fetch reviews this time. Please try again.', 'embedpress'); |
| 149 | return ['ok' => false, 'message' => $msg]; |
| 150 | } |
| 151 | |
| 152 | $reviews = isset($body['reviews']) && is_array($body['reviews']) ? array_values($body['reviews']) : []; |
| 153 | $meta = isset($body['meta']) && is_array($body['meta']) ? $body['meta'] : []; |
| 154 | return ['ok' => true, 'reviews' => $reviews, 'meta' => $meta]; |
| 155 | } |
| 156 | |
| 157 | // ------------------------------------------------------------------ |
| 158 | // Connect / disconnect — Bearer-token issuance with api.embedpress.com. |
| 159 | // The proxy refuses enqueue / status calls without a valid token bound |
| 160 | // to this site's home_url + fingerprint, so the admin clicks Connect |
| 161 | // exactly once before the managed scrape path can run. |
| 162 | // ------------------------------------------------------------------ |
| 163 | |
| 164 | /** |
| 165 | * Stable per-install fingerprint sent to the proxy at connect time. |
| 166 | * Combines home_url with the site's AUTH_KEY (or an install-time UUID |
| 167 | * fallback) so two installs at the same URL still produce different |
| 168 | * fingerprints. The proxy stores it and rejects later calls that |
| 169 | * present a mismatching fingerprint, making a leaked token useless on |
| 170 | * any other install. |
| 171 | */ |
| 172 | public static function compute_fingerprint(): string |
| 173 | { |
| 174 | $secret = defined('AUTH_KEY') && AUTH_KEY !== '' ? AUTH_KEY : ''; |
| 175 | if ($secret === '') { |
| 176 | // No salts (e.g. fresh wp-config) — fall back to a stable |
| 177 | // install-time UUID so the fingerprint still differs per install. |
| 178 | $secret = get_option('embedpress_install_uuid'); |
| 179 | if (!$secret) { |
| 180 | $secret = wp_generate_uuid4(); |
| 181 | update_option('embedpress_install_uuid', $secret, false); |
| 182 | } |
| 183 | } |
| 184 | $material = home_url() . '|' . $secret . '|embedpress-google-reviews'; |
| 185 | return hash('sha256', $material); |
| 186 | } |
| 187 | |
| 188 | /** |
| 189 | * Return the stored auth blob, or [] if the site is not connected. |
| 190 | * Cheap call — single wp_options lookup. |
| 191 | */ |
| 192 | public static function get_auth(): array |
| 193 | { |
| 194 | $a = get_option(self::OPT_AUTH); |
| 195 | return is_array($a) && !empty($a['token']) ? $a : []; |
| 196 | } |
| 197 | |
| 198 | public static function is_connected(): bool |
| 199 | { |
| 200 | return self::get_auth() !== []; |
| 201 | } |
| 202 | |
| 203 | /** |
| 204 | * Perform the Connect handshake with the proxy. Stores the returned |
| 205 | * token + binding metadata in wp_options on success. Returns |
| 206 | * ['ok' => true, ...] on success, ['ok' => false, 'message' => ...] |
| 207 | * on failure so the REST controller can surface the reason verbatim. |
| 208 | */ |
| 209 | public static function connect(): array |
| 210 | { |
| 211 | $payload = [ |
| 212 | 'site_url' => home_url(), |
| 213 | 'fingerprint' => self::compute_fingerprint(), |
| 214 | 'admin_email' => get_option('admin_email'), |
| 215 | 'plugin_version' => defined('EMBEDPRESS_VERSION') ? EMBEDPRESS_VERSION : '', |
| 216 | 'wp_version' => get_bloginfo('version'), |
| 217 | 'tier' => \EmbedPress\Includes\Classes\Helper::is_pro_active() ? 'pro' : 'free', |
| 218 | ]; |
| 219 | |
| 220 | $response = wp_remote_post(self::endpoint() . '/connect.php', [ |
| 221 | 'timeout' => 15, |
| 222 | 'headers' => [ |
| 223 | 'Content-Type' => 'application/json', |
| 224 | 'Accept' => 'application/json', |
| 225 | ], |
| 226 | 'body' => wp_json_encode($payload), |
| 227 | ]); |
| 228 | |
| 229 | if (is_wp_error($response)) { |
| 230 | return ['ok' => false, 'message' => $response->get_error_message()]; |
| 231 | } |
| 232 | |
| 233 | $code = (int) wp_remote_retrieve_response_code($response); |
| 234 | $body = json_decode((string) wp_remote_retrieve_body($response), true); |
| 235 | |
| 236 | if ($code !== 200 || !is_array($body) || empty($body['token'])) { |
| 237 | $msg = is_array($body) && !empty($body['message']) |
| 238 | ? (string) $body['message'] |
| 239 | : sprintf(__('Connect failed (HTTP %d).', 'embedpress'), $code); |
| 240 | return ['ok' => false, 'message' => $msg]; |
| 241 | } |
| 242 | |
| 243 | $auth = [ |
| 244 | 'token' => (string) $body['token'], |
| 245 | 'site_id' => (string) ($body['site_id'] ?? ''), |
| 246 | 'home_url' => (string) ($body['home_url'] ?? home_url()), |
| 247 | 'fingerprint' => $payload['fingerprint'], |
| 248 | 'tier' => (string) ($body['tier'] ?? 'free'), |
| 249 | 'connected_at' => (int) ($body['issued_at'] ?? time()), |
| 250 | ]; |
| 251 | update_option(self::OPT_AUTH, $auth, false); // autoload=no |
| 252 | return ['ok' => true] + $auth; |
| 253 | } |
| 254 | |
| 255 | /** |
| 256 | * Forget the locally-stored token. The proxy-side row stays put (so an |
| 257 | * audit trail remains); a future Connect just rotates the token. |
| 258 | */ |
| 259 | public static function disconnect(): void |
| 260 | { |
| 261 | delete_option(self::OPT_AUTH); |
| 262 | } |
| 263 | |
| 264 | /** |
| 265 | * Build the headers every proxy call sends. Adds the Authorization |
| 266 | * Bearer + X-EmbedPress-Fingerprint when the site is connected. Falls |
| 267 | * back to plain headers (no auth) when not connected so the proxy can |
| 268 | * answer with a clear 401 instead of a confusing 400. |
| 269 | */ |
| 270 | private static function auth_headers(): array |
| 271 | { |
| 272 | $headers = [ |
| 273 | 'Content-Type' => 'application/json', |
| 274 | 'Accept' => 'application/json', |
| 275 | 'X-EmbedPress-Site' => home_url(), |
| 276 | ]; |
| 277 | $auth = self::get_auth(); |
| 278 | if (!empty($auth['token'])) { |
| 279 | $headers['Authorization'] = 'Bearer ' . $auth['token']; |
| 280 | // Some hosts strip Authorization at FPM; the proxy honors this |
| 281 | // alternate header as a fallback so connectivity still works. |
| 282 | $headers['X-EmbedPress-Token'] = $auth['token']; |
| 283 | $headers['X-EmbedPress-Fingerprint'] = !empty($auth['fingerprint']) |
| 284 | ? $auth['fingerprint'] |
| 285 | : self::compute_fingerprint(); |
| 286 | } |
| 287 | return $headers; |
| 288 | } |
| 289 | |
| 290 | /** |
| 291 | * Surface a 401/403 from the proxy as a single "Disconnected — please |
| 292 | * reconnect" status string. Caller passes the parsed body + HTTP code; |
| 293 | * returns the message to write into the store, or '' if the response |
| 294 | * wasn't an auth failure. |
| 295 | */ |
| 296 | private static function auth_failure_message(int $code, $body): string |
| 297 | { |
| 298 | if ($code !== 401 && $code !== 403) { |
| 299 | return ''; |
| 300 | } |
| 301 | $err = is_array($body) ? (string) ($body['error'] ?? '') : ''; |
| 302 | if ($err === 'site_mismatch' || $err === 'fingerprint_mismatch') { |
| 303 | return __('This site’s connection has expired. Reconnect to EmbedPress API to continue.', 'embedpress'); |
| 304 | } |
| 305 | // missing_token / invalid_token / revoked / banned all map to the |
| 306 | // same user action: reconnect. |
| 307 | return __('Not connected to EmbedPress API. Open EmbedPress → Google Reviews and click Connect.', 'embedpress'); |
| 308 | } |
| 309 | |
| 310 | /** |
| 311 | * Renderer filter: kicks in only when the prior handler (Apify) didn't |
| 312 | * start a job AND no user Apify token is configured. Returns true if |
| 313 | * we successfully enqueued (or cache-hit) so the caller skips its |
| 314 | * sync ≤5 fallback. |
| 315 | */ |
| 316 | public function start_fetch_job_filter($started, $place_id, $args) |
| 317 | { |
| 318 | if ($started) { |
| 319 | return $started; |
| 320 | } |
| 321 | // EmbedPress managed scraper WINS by default: it fetches ALL reviews |
| 322 | // for free via api.embedpress.com/google-reviews/v1, which is strictly |
| 323 | // better than the Apify ≤preview run or the Google Places ≤5 API. We |
| 324 | // only fall through to those when the managed path can't run (not |
| 325 | // connected, or the proxy refused) — handled inside start_job(), which |
| 326 | // returns false on failure so the caller's Apify/Google fallback runs. |
| 327 | return self::start_job((string) $place_id, is_array($args) ? $args : []); |
| 328 | } |
| 329 | |
| 330 | /** |
| 331 | * Single-place sync fallback for the renderer's get_reviews_for_render. |
| 332 | * If the proxy has the place CACHED, we get reviews inline — no job |
| 333 | * dance. Otherwise enqueue + return null (caller renders empty; cron |
| 334 | * polls in the background and the block re-renders when reviews land). |
| 335 | */ |
| 336 | public function pre_fetch_filter($pre, $place_id, $args) |
| 337 | { |
| 338 | if (is_array($pre)) { |
| 339 | return $pre; |
| 340 | } |
| 341 | // EmbedPress managed scraper WINS by default (all reviews, free) over |
| 342 | // both the user's Apify token and a Google key. Try it first: a cache |
| 343 | // hit returns reviews inline; a miss enqueues a background job and |
| 344 | // returns null so the renderer shows empty until the cron poller |
| 345 | // populates the store. Only when the managed path can't run (not |
| 346 | // connected / proxy unreachable — try_inline_cache_or_enqueue returns |
| 347 | // null) do we fall through to the Apify/Google API paths. |
| 348 | $result = self::try_inline_cache_or_enqueue((string) $place_id, is_array($args) ? $args : []); |
| 349 | return is_array($result) ? $result : null; |
| 350 | } |
| 351 | |
| 352 | /** |
| 353 | * Public entry: enqueue a scrape for $place_id at the proxy. If the |
| 354 | * proxy reports a CACHE HIT, write the reviews to the store immediately |
| 355 | * and return true with status=done. If the proxy queues, store the |
| 356 | * job_id + schedule the WP-cron poller. |
| 357 | * |
| 358 | * Returns true if the job moved forward (cache hit or queued); false |
| 359 | * if the proxy refused the request or is unreachable. |
| 360 | */ |
| 361 | public static function start_job(string $place_id, array $args = []): bool |
| 362 | { |
| 363 | if ($place_id === '') { |
| 364 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => __('No place selected.', 'embedpress')]); |
| 365 | return false; |
| 366 | } |
| 367 | |
| 368 | $max = self::resolve_max($args); |
| 369 | |
| 370 | // Refuse to call the proxy if we don't have a token — the admin |
| 371 | // hasn't clicked Connect yet. Surface a clear "please connect" |
| 372 | // status so the UI can render a "Connect to EmbedPress API" CTA |
| 373 | // instead of a confusing HTTP error. |
| 374 | if (!self::is_connected()) { |
| 375 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, [ |
| 376 | 'message' => __('Not connected to EmbedPress API. Click Connect in the Google Reviews settings to start fetching reviews.', 'embedpress'), |
| 377 | ]); |
| 378 | return false; |
| 379 | } |
| 380 | |
| 381 | // A USER-INITIATED REFETCH (the Refetch button) must NEVER come from the |
| 382 | // proxy's cache — the whole point of clicking Refetch is "go get fresh |
| 383 | // data now". So force refresh=true whenever the caller flags user_refetch |
| 384 | // (set by the REST refetch handler), regardless of quick/full mode. This |
| 385 | // is also what lets a Refetch replace a poisoned/partial cache row (e.g. |
| 386 | // an exhausted=1 row holding only 3 reviews from before a worker fix). |
| 387 | // Auto/render-triggered fetches omit the flag and still use the cache. |
| 388 | $force_refresh = !empty($args['user_refetch']) |
| 389 | || (array_key_exists('incremental', $args) && $args['incremental'] === false); |
| 390 | |
| 391 | $payload = [ |
| 392 | 'place_id' => $place_id, |
| 393 | 'max' => $max, |
| 394 | ]; |
| 395 | if ($force_refresh) { |
| 396 | $payload['refresh'] = true; |
| 397 | } |
| 398 | |
| 399 | $response = wp_remote_post(self::endpoint() . '/enqueue.php', [ |
| 400 | 'timeout' => (int) apply_filters('embedpress/google_reviews/managed_enqueue_timeout', 10, $place_id, $args), |
| 401 | 'headers' => self::auth_headers(), |
| 402 | 'body' => wp_json_encode($payload), |
| 403 | ]); |
| 404 | |
| 405 | if (is_wp_error($response)) { |
| 406 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => $response->get_error_message()]); |
| 407 | return false; |
| 408 | } |
| 409 | $code = (int) wp_remote_retrieve_response_code($response); |
| 410 | $body = json_decode((string) wp_remote_retrieve_body($response), true); |
| 411 | |
| 412 | // Auth failure: clear stored credentials when the proxy says the |
| 413 | // token is invalid/revoked so the next page load shows "Connect" |
| 414 | // again instead of looping on a dead token. |
| 415 | $auth_msg = self::auth_failure_message($code, $body); |
| 416 | if ($auth_msg !== '') { |
| 417 | if ($code === 401 && is_array($body) && in_array(($body['error'] ?? ''), ['invalid_token', 'missing_token'], true)) { |
| 418 | self::disconnect(); |
| 419 | } |
| 420 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => $auth_msg]); |
| 421 | return false; |
| 422 | } |
| 423 | |
| 424 | // 429: rate-limited. Surface verbatim so the user understands. |
| 425 | if ($code === 429) { |
| 426 | $msg = is_array($body) && !empty($body['message']) ? $body['message'] : __('EmbedPress API is busy. Please try again in a minute.', 'embedpress'); |
| 427 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => $msg]); |
| 428 | return false; |
| 429 | } |
| 430 | |
| 431 | // 200 + cached=true → inline write, no job needed. Merge (append) rather |
| 432 | // than reset+replace so a cached result with fewer reviews can't shrink |
| 433 | // the stored count — same never-lose rule as the polled 'done' path. |
| 434 | if ($code === 200 && is_array($body) && !empty($body['cached'])) { |
| 435 | $reviews = is_array($body['reviews'] ?? null) ? $body['reviews'] : []; |
| 436 | $meta = is_array($body['meta'] ?? null) ? $body['meta'] : []; |
| 437 | GoogleReviewsStore::append_reviews($place_id, $reviews, $meta, 'managed', true); |
| 438 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_DONE, ['message' => null, 'run_id' => null]); |
| 439 | return true; |
| 440 | } |
| 441 | |
| 442 | // 202 + job_id → queued. Save run_id + start polling. |
| 443 | if ($code === 202 && is_array($body) && !empty($body['job_id'])) { |
| 444 | // DO NOT reset_reviews here. Wiping the stored set upfront made the |
| 445 | // count drop to 0 and "climb back up" during a refetch (570 → 0 → |
| 446 | // 430…), and if the new scrape got fewer reviews or failed midway we |
| 447 | // LOST the reviews we already had. Instead keep the existing reviews |
| 448 | // visible while fetching; the 'done' poll merges the new set in via |
| 449 | // append_reviews() (dedup union), so a refetch only ever ADDS or |
| 450 | // refreshes — it never loses what we already stored. |
| 451 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_RUNNING, [ |
| 452 | 'run_id' => (string) $body['job_id'], |
| 453 | // Initial placeholder shown only until the first poll pulls the |
| 454 | // worker's real live status ("Looking up your place…", etc.). |
| 455 | 'message' => __('Starting…', 'embedpress'), |
| 456 | ]); |
| 457 | // First poll fires in 3s — the worker starts writing the |
| 458 | // progress count within ~1.5s of claiming the job, so 3s |
| 459 | // gets the user a number on screen almost immediately. |
| 460 | $delay = isset($body['poll_after_seconds']) ? max(3, (int) $body['poll_after_seconds']) : 3; |
| 461 | self::schedule_poll($place_id, $delay); |
| 462 | return true; |
| 463 | } |
| 464 | |
| 465 | // Anything else → surface as failure. |
| 466 | $msg = is_array($body) && !empty($body['message']) |
| 467 | ? (string) $body['message'] |
| 468 | : __('We couldn’t reach EmbedPress API. Please try again.', 'embedpress'); |
| 469 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => $msg]); |
| 470 | return false; |
| 471 | } |
| 472 | |
| 473 | /** |
| 474 | * Try a cache-hit pre-fetch synchronously. If the proxy has the place |
| 475 | * cached, the response is inline — return it without enqueuing. If |
| 476 | * not cached, kick off start_job() to put the place in the queue and |
| 477 | * return null (the caller renders empty; cron picks it up). |
| 478 | */ |
| 479 | private static function try_inline_cache_or_enqueue(string $place_id, array $args): ?array |
| 480 | { |
| 481 | $row = GoogleReviewsStore::get($place_id); |
| 482 | if ($row && ($row['fetch_status'] ?? '') === GoogleReviewsStore::STATUS_RUNNING) { |
| 483 | return null; // already polling |
| 484 | } |
| 485 | |
| 486 | // Cheap probe — same payload as start_job but with max=1 so a cache |
| 487 | // miss doesn't end up scraping more than necessary if the proxy uses |
| 488 | // requested_max to size the eventual run. |
| 489 | $started = self::start_job($place_id, $args); |
| 490 | if (!$started) { |
| 491 | return null; |
| 492 | } |
| 493 | // start_job() already wrote to the store on cache hit; pull it back |
| 494 | // out and return so the renderer can serve this request inline. |
| 495 | $row = GoogleReviewsStore::get($place_id); |
| 496 | if ($row && ($row['fetch_status'] ?? '') === GoogleReviewsStore::STATUS_DONE) { |
| 497 | return [ |
| 498 | 'reviews' => is_array($row['reviews']) ? $row['reviews'] : [], |
| 499 | 'meta' => is_array($row['meta']) ? $row['meta'] : [], |
| 500 | ]; |
| 501 | } |
| 502 | return null; |
| 503 | } |
| 504 | |
| 505 | /** |
| 506 | * Schedule a one-off WP-cron tick to poll the proxy's status. |
| 507 | * Minimum 3s — anything tighter risks overwhelming WP cron if many |
| 508 | * places are running simultaneously, and the worker's progress writes |
| 509 | * at ~1.8s cadence so a 3s poll matches that closely. |
| 510 | */ |
| 511 | public static function schedule_poll(string $place_id, int $delay = 3): void |
| 512 | { |
| 513 | if (!wp_next_scheduled(self::CRON_HOOK, [$place_id])) { |
| 514 | wp_schedule_single_event(time() + max(3, $delay), self::CRON_HOOK, [$place_id]); |
| 515 | } |
| 516 | } |
| 517 | |
| 518 | /** |
| 519 | * Cron callback. Polls status.php?job_id=…; when status=done, writes |
| 520 | * reviews to the store + finalizes. When status=running, reschedules |
| 521 | * itself. When failed, surfaces the proxy's message. |
| 522 | */ |
| 523 | public function poll_job($place_id): void |
| 524 | { |
| 525 | $place_id = (string) $place_id; |
| 526 | $row = GoogleReviewsStore::get($place_id); |
| 527 | if (!$row || ($row['fetch_status'] ?? '') !== GoogleReviewsStore::STATUS_RUNNING) { |
| 528 | return; // cancelled or already done |
| 529 | } |
| 530 | $job_id = (string) ($row['fetch_run_id'] ?? ''); |
| 531 | if ($job_id === '') { |
| 532 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, ['message' => __('Something went wrong. Please click Refetch to try again.', 'embedpress')]); |
| 533 | return; |
| 534 | } |
| 535 | |
| 536 | $response = wp_remote_get( |
| 537 | self::endpoint() . '/status.php?' . http_build_query(['job_id' => $job_id]), |
| 538 | [ |
| 539 | 'timeout' => 10, |
| 540 | 'headers' => self::auth_headers(), |
| 541 | ] |
| 542 | ); |
| 543 | if (is_wp_error($response)) { |
| 544 | // Transient error — retry in a moment. If the proxy is permanently |
| 545 | // down, multiple reschedules eventually look like "stuck"; the user |
| 546 | // can cancel via the admin Refetch UI. Longer delay on errors so |
| 547 | // we don't hammer a down proxy. |
| 548 | self::schedule_poll($place_id, 10); |
| 549 | return; |
| 550 | } |
| 551 | $code = (int) wp_remote_retrieve_response_code($response); |
| 552 | $body = json_decode((string) wp_remote_retrieve_body($response), true); |
| 553 | |
| 554 | // Auth failure during polling = user disconnected (or token revoked). |
| 555 | // Stop the cron loop and surface a clear status — don't reschedule. |
| 556 | $auth_msg = self::auth_failure_message($code, $body); |
| 557 | if ($auth_msg !== '') { |
| 558 | if ($code === 401 && is_array($body) && in_array(($body['error'] ?? ''), ['invalid_token', 'missing_token'], true)) { |
| 559 | self::disconnect(); |
| 560 | } |
| 561 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, [ |
| 562 | 'message' => $auth_msg, |
| 563 | 'run_id' => null, |
| 564 | ]); |
| 565 | return; |
| 566 | } |
| 567 | |
| 568 | if ($code === 404) { |
| 569 | // Proxy lost the job (cache eviction, DB reset, …). Tell the user. |
| 570 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, [ |
| 571 | 'message' => __('That fetch attempt timed out. Please click Refetch to try again.', 'embedpress'), |
| 572 | 'run_id' => null, |
| 573 | ]); |
| 574 | return; |
| 575 | } |
| 576 | if ($code !== 200 || !is_array($body)) { |
| 577 | self::schedule_poll($place_id, 10); |
| 578 | return; |
| 579 | } |
| 580 | |
| 581 | $status = (string) ($body['status'] ?? ''); |
| 582 | switch ($status) { |
| 583 | case 'queued': |
| 584 | case 'running': |
| 585 | // Live progress: surface the proxy's status message so the |
| 586 | // admin UI shows the same wording the scraper reports. |
| 587 | $msg = !empty($body['message']) ? (string) $body['message'] : __('Fetching reviews…', 'embedpress'); |
| 588 | $so_far = isset($body['fetched_count']) ? (int) $body['fetched_count'] : 0; |
| 589 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_RUNNING, [ |
| 590 | 'message' => $msg, |
| 591 | 'so_far' => $so_far, |
| 592 | ]); |
| 593 | // Worker writes progress at ~1.8s per scroll round; 3s |
| 594 | // matches that closely so the visible count updates feel |
| 595 | // continuous instead of jumpy. |
| 596 | self::schedule_poll($place_id, 3); |
| 597 | return; |
| 598 | |
| 599 | case 'done': |
| 600 | $reviews = is_array($body['reviews'] ?? null) ? $body['reviews'] : []; |
| 601 | $meta = is_array($body['meta'] ?? null) ? $body['meta'] : []; |
| 602 | GoogleReviewsStore::append_reviews($place_id, $reviews, $meta, 'managed', true); |
| 603 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_DONE, [ |
| 604 | 'message' => null, |
| 605 | 'run_id' => null, |
| 606 | ]); |
| 607 | return; |
| 608 | |
| 609 | case 'failed': |
| 610 | GoogleReviewsStore::set_job($place_id, GoogleReviewsStore::STATUS_FAILED, [ |
| 611 | 'message' => !empty($body['message']) ? (string) $body['message'] : __('Couldn’t fetch reviews this time. Please try Refetch again in a moment.', 'embedpress'), |
| 612 | 'run_id' => null, |
| 613 | ]); |
| 614 | return; |
| 615 | |
| 616 | default: |
| 617 | // Unknown status — give the proxy one more chance with a |
| 618 | // longer delay so we don't tight-loop on a bad response. |
| 619 | self::schedule_poll($place_id, 10); |
| 620 | } |
| 621 | } |
| 622 | |
| 623 | /** |
| 624 | * What `max` to send to the proxy enqueue endpoint. |
| 625 | * |
| 626 | * Contract with `api.embedpress.com/google-reviews/v1/enqueue.php`: |
| 627 | * - 0 means "scrape every review Google exposes" (worker scrolls |
| 628 | * until exhausted, capped server-side at MAX_REVIEWS_PER_JOB=1000). |
| 629 | * - >0 is a soft cap that the worker honors as a hard stop. |
| 630 | * |
| 631 | * GLOBAL CAP: 1000 reviews/place max (product + Cloud-Run-free-tier |
| 632 | * decision, 2026-06-19). Requested here AND enforced server-side. |
| 633 | * |
| 634 | * TIER CAP: FREE users get at most FREE_MAX_REVIEWS (10) per place — a |
| 635 | * "request all" (0) is rewritten to 10 so free never pulls the full set. |
| 636 | * PRO is uncapped (0 = all up to the server ceiling). This is the single |
| 637 | * enforcement point: every fetch (add, refetch, render) routes through here. |
| 638 | */ |
| 639 | const FREE_MAX_REVIEWS = 10; |
| 640 | |
| 641 | private static function resolve_max(array $args): int |
| 642 | { |
| 643 | $is_pro = \EmbedPress\Includes\Classes\Helper::is_pro_active(); |
| 644 | $free_cap = (int) apply_filters('embedpress/google_reviews/free_max_reviews', self::FREE_MAX_REVIEWS); |
| 645 | $requested = isset($args['fetch_max']) ? (int) $args['fetch_max'] : 0; |
| 646 | |
| 647 | if (!$is_pro) { |
| 648 | // Free: "all" (0) → free cap; an explicit N → min(N, free cap). |
| 649 | return $requested <= 0 ? $free_cap : min($requested, $free_cap); |
| 650 | } |
| 651 | |
| 652 | // Pro: "all" stays all (proxy expands to its 1000 ceiling); an explicit |
| 653 | // N is bounded by the 1000-review global cap. |
| 654 | if ($requested <= 0) { |
| 655 | return 0; // "all" — proxy caps at MAX_REVIEWS_PER_JOB (1000) |
| 656 | } |
| 657 | $ceiling = (int) apply_filters('embedpress/google_reviews/managed_max_ceiling', 1000); |
| 658 | return min($requested, $ceiling); |
| 659 | } |
| 660 | } |
| 661 |