PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | otto/otto_pixel.php +1482 -233 2.6.5 → 2.7.1 View file →
@@ -5,22 +5,77 @@
5 5 if ( ! defined( 'ABSPATH' ) ) {
6 6 exit;
7 7 }
8 8
9 +if (!defined('METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY')) {
10 + define('METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY', 'metasync_otto_excluded_urls_manual');
11 +}
12 +if (!defined('METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL')) {
13 + define('METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL', 60);
14 +}
15 +
9 16 # uses the simple html dom library
10 17 use simplehtmldom\HtmlDocument;
11 18
12 19 # include the otto class file
13 -require_once plugin_dir_path( __FILE__ ) . '/vendor/autoload.php';
20 +#
21 +# The vendor tree is generated content, unlike the plugin files beside it: an
22 +# update that copies the plugin directory piece by piece can briefly leave this
23 +# file beside an otto/vendor that is missing, half-written or holding a stale
24 +# classmap. A bare require on a missing autoloader is a fatal during plugin
25 +# bootstrap — the whole site, not just OTTO. Load it only when it is actually
26 +# readable, and catch Throwable while doing so: the autoloader is a trampoline
27 +# of bare requires into otto/vendor/composer/, so a tree that is present but
28 +# still mid-copy (or a truncated autoload.php, which parses only to a
29 +# ParseError) would otherwise fatal here too. Either way the parser class
30 +# stays unloaded, and the DOM-parser guard in metasync_start_otto() degrades
31 +# the request to OTTO-less rendering instead of fataling.
32 +$metasync_otto_vendor_autoload = plugin_dir_path( __FILE__ ) . '/vendor/autoload.php';
33 +if ( is_readable( $metasync_otto_vendor_autoload ) ) {
34 + try {
35 + require_once $metasync_otto_vendor_autoload;
36 + } catch ( \Throwable $e ) {
37 + # Deliberately swallowed: any exception raised while loading generated
38 + # autoloader code means this request runs without the DOM parser. The
39 + # guard below turns that into one OTTO-less request rather than a
40 + # bootstrap fatal on the whole site.
41 + }
42 +}
14 43 require_once plugin_dir_path( __FILE__ ) . '/Otto_html_class.php';
15 44 require_once plugin_dir_path( __FILE__ ) . '/Otto_pixel_class.php';
16 45 require_once plugin_dir_path( __FILE__ ) . '/metasync-otto-seo-functions.php';
17 46 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-transient-cache.php';
47 +require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-job-status.php';
18 48 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-render-strategy.php';
19 49 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-config.php';
20 50 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-detector.php';
21 51 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-statistics-database.php';
22 52
53 +/**
54 + * Is $method actually available on $class in this process right now?
55 + *
56 + * A partially updated install can leave a newer copy of one plugin file beside
57 + * an older copy of another — stale opcache bytecode for a single file is enough.
58 + * class_exists() is satisfied by the older copy, and calling a method it does
59 + * not declare is a fatal. Everything in this file runs on the front end, so that
60 + * fatal is a white screen on every page view rather than a degraded feature.
61 + *
62 + * Defined here rather than pulled from a shared utility class on purpose: a
63 + * helper loaded from another file could itself be the stale one.
64 + *
65 + * $class and $method are parameters rather than literals at the call site so the
66 + * check survives static analysis, which would otherwise narrow a literal
67 + * method_exists() on a known class to a constant true — the skew this guards
68 + * against exists only at runtime.
69 + *
70 + * @param string $class Class about to be called.
71 + * @param string $method Method about to be called on it.
72 + * @return bool
73 + */
74 +function metasync_otto_class_provides($class, $method) {
75 + return class_exists($class) && method_exists($class, $method);
76 +}
77 +
23 78 # OPTIMIZED: get the metasync options (cached in static class)
24 79 $metasync_options = Metasync_Otto_Config::get_options();
25 80
26 81 # OTTO SSR is always enabled by default
@@ -44,9 +99,9 @@
44 99 # Use endpoint manager to get the correct domain
45 100 $otto_domain = 'sa.searchatlas.com'; # default
46 101 if (class_exists('Metasync_Endpoint_Manager')) {
47 102 $otto_api_domain = Metasync_Endpoint_Manager::get_endpoint('OTTO_API_DOMAIN');
48 - $parsed = parse_url($otto_api_domain);
103 + $parsed = wp_parse_url($otto_api_domain);
49 104 if (!empty($parsed['host'])) {
50 105 $otto_domain = $parsed['host'];
51 106 }
52 107 }
@@ -62,8 +117,49 @@
62 117 echo $otto_tag;
63 118 }, 1); # Priority 1 to output early in head
64 119
65 120 /**
121 + * Scheduling seam for the OTTO pipeline.
122 + *
123 + * wp_schedule_single_event() returns false on failure and the old pipeline
124 + * ignored it, so a broken cron table acknowledged URLs that were never
125 + * queued. Every schedule in this pipeline goes through this seam so the
126 + * return value is honored (and tests can observe scheduling).
127 + */
128 +function metasync_otto_schedule_single_event($timestamp, $hook, $args = array()) {
129 + return wp_schedule_single_event($timestamp, $hook, $args) === true;
130 +}
131 +
132 +/**
133 + * Dedupe seam: does this URL already have live per-URL work in flight?
134 + *
135 + * Combines the durable status store with the raw cron table so a re-delivered
136 + * webhook cannot stack a second job behind a live one.
137 + */
138 +function metasync_otto_job_already_scheduled($route) {
139 + if (class_exists('Metasync_Otto_Job_Status', false)
140 + && Metasync_Otto_Job_Status::has_active($route)) {
141 + return true;
142 + }
143 + return wp_next_scheduled('metasync_process_otto_crawl_url_job', array($route)) !== false;
144 +}
145 +
146 +/**
147 + * Cron-table dedupe seam: does a per-URL cron event already exist for this URL?
148 + *
149 + * The pending-queue drainer must dedupe against the scheduler table only.
150 + * Every URL it takes off the durable queue carries a fresh webhook
151 + * accepted/batch_overflow ledger row; that row exists so a re-delivered
152 + * webhook cannot stack a second job behind live work, and consulting it
153 + * from the drainer — after take_pending() has already removed the URL
154 + * from the queue — silently dropped every overflow URL instead of
155 + * draining it. Only a real, still-pending cron event may veto here.
156 + */
157 +function metasync_otto_job_event_pending($route) {
158 + return wp_next_scheduled('metasync_process_otto_crawl_url_job', array($route)) !== false;
159 +}
160 +
161 +/**
66 162 * Start end point to handle requests on page updates
67 163 * The Otto Crawler will call this end point once a page is updated
68 164 **/
69 165
@@ -68,9 +164,9 @@
68 164 **/
69 165
70 166 # function to register the route
71 167 function metasync_otto_crawl_notify($request){
72 -
168 +
73 169 # get request data params
74 170 $data = $request->get_json_params();
75 171
76 172 # fields
@@ -93,15 +189,29 @@
93 189
94 190 # load otto pixel
95 191 $otto_pixel = new Metasync_otto_pixel(false);
96 192
97 - # save the otto data first
193 + # save the otto data first (cheap local DB write — kept synchronous)
98 194 $otto_pixel->save_crawl_data($data);
99 195
100 - # Collect URLs that need cache warming after purge
101 - $urls_to_warm = array();
196 + # Defer the expensive per-URL work (OTTO API fetch, post meta sync,
197 + # per-URL cache purge) to background jobs. The webhook caller enforces a
198 + # strict response-time budget, so the response acknowledges SCHEDULING,
199 + # never execution. The per-URL status store records what actually
200 + # happened to each URL once the jobs run.
201 + #
202 + # Cap the number of per-URL cron jobs scheduled per webhook batch so a
203 + # large crawl cannot overwhelm WP-Cron on shared hosts. URLs past the cap
204 + # are queued durably and worked off by a self-rescheduling drainer job —
205 + # never silently dropped.
206 + $max_jobs_per_batch = defined('METASYNC_MAX_JOBS_PER_BATCH') ? METASYNC_MAX_JOBS_PER_BATCH : 25;
207 + $now = time();
208 + $routes_to_process = array();
209 + $accepted = 0;
210 + $deferred = 0;
211 + $rejected = 0;
212 + $scheduled_this_batch = 0;
102 213
103 - # now we have the data sets
104 214 foreach($data['urls'] AS $key => $url){
105 215 # prepare the route
106 216 $route = $data['domain'] . $url;
107 217
@@ -116,92 +226,372 @@
116 226 # and metasync_otto_is_url_available() incorrectly treated it as a 404).
117 227 # Manual exclusions (auto_excluded = 0) are left untouched.
118 228 metasync_otto_remove_auto_exclusion($route);
119 229
120 - # Skip manually excluded URLs - don't process OTTO data for them
230 + # Skip manually excluded URLs - don't queue OTTO processing for them
121 231 if (metasync_is_otto_url_excluded($route)) {
122 232 continue;
123 233 }
124 234
125 - # Step 1: Warm OTTO transient cache (fetch fresh suggestions from OTTO API into WP transient)
235 + # Every valid URL belongs to the batched cache-purge set even when its
236 + # per-URL meta-sync job is deduped or deferred: the crawler hit all of
237 + # them, so the caches for all of them must be refreshed.
238 + $routes_to_process[] = $route;
239 +
240 + # Duplicate delivery: an active job already owns this URL. Idempotent
241 + # no-op — not re-scheduled, not queued, not counted as new work.
242 + if (metasync_otto_job_already_scheduled($route)) {
243 + continue;
244 + }
245 +
246 + if ($scheduled_this_batch >= $max_jobs_per_batch) {
247 + # Batch overflow: queue durably for the drainer instead of dropping.
248 + if (Metasync_Otto_Job_Status::enqueue_pending($route)) {
249 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', 0, 'batch_overflow');
250 + $deferred++;
251 + } else {
252 + $rejected++;
253 + }
254 + continue;
255 + }
256 +
257 + # Queue the per-URL processing for background execution. The handler
258 + # (metasync_handle_otto_crawl_url_job) performs transient warming,
259 + # SEO meta sync, and per-URL host cache purge.
260 + # Offset each event by $key seconds to avoid wp_schedule_single_event()
261 + # silently dropping duplicates when timestamp + hook + args collide.
262 + # A false return means cron refused the event — fall back to the
263 + # durable queue rather than acknowledging a URL nobody will process.
264 + if (metasync_otto_schedule_single_event($now + $key, 'metasync_process_otto_crawl_url_job', array($route))) {
265 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', 0, '');
266 + $accepted++;
267 + $scheduled_this_batch++;
268 + } elseif (Metasync_Otto_Job_Status::enqueue_pending($route)) {
269 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', 0, 'cron_refused');
270 + $deferred++;
271 + } else {
272 + $rejected++;
273 + }
274 + }
275 +
276 + # Schedule a single batch job for the edge CDN purge.
277 + # The batch waits for per-URL stragglers (retries) before purging, so a
278 + # retried URL's fresh content is what reaches the edge. Multi-URL purge
279 + # in a single API call keeps edge-provider request counts low.
280 + if (!empty($routes_to_process)) {
281 + $batch_time = $now + count($data['urls']) + 5; // run after all per-URL jobs
282 + metasync_otto_schedule_single_event($batch_time, 'metasync_process_otto_batch_cache_job', array($routes_to_process));
283 + }
284 +
285 + # Work off any overflow (or cron-refused URLs) from the durable queue.
286 + if ($deferred > 0) {
287 + metasync_otto_schedule_single_event($now + 120, 'metasync_otto_pending_drainer', array());
288 + }
289 +
290 + # Return 200 immediately so the webhook caller does not time out, with an
291 + # honest accounting of what happened to the batch.
292 + return new WP_REST_Response(array(
293 + 'success' => true,
294 + 'message' => 'OTTO crawl notification received',
295 + 'accepted' => $accepted,
296 + 'deferred' => $deferred,
297 + 'rejected' => $rejected,
298 + ), 200);
299 +}
300 +
301 +/**
302 + * Background handler for a single OTTO crawl-notify URL.
303 + *
304 + * Pipeline per URL:
305 + * 1. Warm the OTTO transient cache — this is the ONE live API call the job
306 + * makes; the warmed suggestions are handed to the meta sync so the
307 + * endpoint is never fetched twice per URL.
308 + * 2. Sync SEO post meta (metasync_process_otto_seo_data) and branch on its
309 + * outcome: only SUCCESS / NO_CHANGE reach the purge step.
310 + * 3. Clear the per-URL host cache and re-warm it.
311 + *
312 + * Retryable outcomes (API timeout / 5xx / 429 / lock contention / CPU deferral)
313 + * retry with exponential backoff up to METASYNC_OTTO_JOB_MAX_RETRIES; the
314 + * exhaustion event is recorded via metasync_record_failed_action() for
315 + * Site Health. Permanent outcomes (not connected, excluded/404 URL, API auth
316 + * or input error) never retry and never purge.
317 + *
318 + * @param string $route Fully-qualified URL to process.
319 + * @param int $retry_count Current retry attempt (0 = first run).
320 + */
321 +function metasync_handle_otto_crawl_url_job($route = '', $retry_count = 0) {
322 + // WP-Cron events persist independently of plugin code. A stale or malformed
323 + // event may therefore invoke this callback without its required route.
324 + // Docblock type isn't enforced at runtime; a stale cron record can pass a non-string.
325 + // @phpstan-ignore-next-line function.alreadyNarrowedType
326 + if (!is_string($route) || $route === '') {
327 + error_log('MetaSync OTTO: skipping crawl-url job without a valid route.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
328 + return;
329 + }
330 +
331 + $max_retries = defined('METASYNC_OTTO_JOB_MAX_RETRIES') ? METASYNC_OTTO_JOB_MAX_RETRIES : 3;
332 +
333 + # Shared retry/terminal bookkeeping so every failure path stays consistent.
334 + $schedule_retry = function ($reason) use ($route, $retry_count, $max_retries) {
335 + if ($retry_count < $max_retries) {
336 + # Exponential backoff (60s, 120s, 240s) plus jitter so a batch of
337 + # failures cannot stampede the API on the same tick.
338 + $delay = 60 * pow(2, $retry_count) + wp_rand(0, 15);
339 + $scheduled = metasync_otto_schedule_single_event(time() + $delay, 'metasync_process_otto_crawl_url_job', array($route, $retry_count + 1));
340 + if (!$scheduled && Metasync_Otto_Job_Status::enqueue_pending($route)) {
341 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', $retry_count + 1, $reason . '/cron_refused');
342 + } else {
343 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_RETRYING, 'job', $retry_count + 1, $reason);
344 + }
345 + error_log('MetaSync OTTO: retrying crawl-url job for ' . $route . ' (attempt ' . ($retry_count + 1) . '/' . $max_retries . ') reason=' . $reason . ' in ' . $delay . 's'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
346 + } else {
347 + metasync_record_failed_action('metasync_process_otto_crawl_url_job');
348 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'job', $retry_count, 'exhausted:' . $reason);
349 + error_log('MetaSync OTTO: background crawl-url job permanently failed for ' . $route . ' after ' . $max_retries . ' retries, last reason=' . $reason); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
350 + }
351 + };
352 +
353 + try {
354 + # Not connected: retrying cannot help, and there is nothing to purge.
126 355 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
127 - if (!empty($otto_uuid)) {
128 - $transient_cache = new Metasync_Otto_Transient_Cache($otto_uuid);
129 - $transient_cache->warm_cache($route);
356 + # A corrupted option row can hold a non-string UUID; the docblock
357 + # contract alone does not enforce that at runtime.
358 + # @phpstan-ignore-next-line function.alreadyNarrowedType
359 + if (!is_string($otto_uuid) || $otto_uuid === '') {
360 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'job', $retry_count, 'not_connected');
361 + return;
130 362 }
131 363
364 + # Step 1: Warm OTTO transient cache (fetch fresh suggestions from OTTO
365 + # API into WP transient — the rate limiter, circuit breaker, and
366 + # request lock all live inside this call).
367 + $transient_cache = new Metasync_Otto_Transient_Cache($otto_uuid);
368 + $warmed = $transient_cache->warm_cache($route);
369 + if ($warmed === false) {
370 + # A 4xx refusal (bad key, revoked project) is permanent: retrying
371 + # cannot change the answer, so fail fast instead of burning the
372 + # attempt budget on warm calls that will keep being rejected.
373 + if ($transient_cache->last_failure_is_permanent()) {
374 + metasync_record_failed_action('metasync_process_otto_crawl_url_job');
375 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'job', $retry_count, 'permanent:warm_rejected');
376 + error_log('MetaSync OTTO: crawl-url job failed permanently for ' . $route . ' (warm request rejected by the API)'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
377 + return;
378 + }
379 + # Transient API failure (timeout / 5xx / breaker open). No meta
380 + # write, no purge — straight to the retry path.
381 + $schedule_retry('warm_failed');
382 + return;
383 + }
384 +
132 385 # Step 2: Write SEO post meta synchronously BEFORE the cache purge.
133 - # This ensures the DB is fully up-to-date when Kinsta (or any host) re-populates
134 - # the cache on the very next request — eliminating the race condition where the
135 - # 1-second scheduled job hadn't run yet and stale meta got cached.
136 - metasync_process_otto_seo_data($route);
386 + # This ensures the DB is fully up-to-date when the host re-populates
387 + # the cache on the very next request. The warmed suggestions are
388 + # passed through so the sync makes NO second API call.
389 + # allow_defer=false: the crawl_url_job retry mechanism owns failure
390 + # handling here; letting the sync self-reschedule caused an
391 + # unbounded cron pile-up.
392 + # warm_cache() may hand back a non-array truthy on contract drift;
393 + # the sync treats that as "no prefetched suggestions".
394 + # @phpstan-ignore-next-line function.alreadyNarrowedType
395 + $prefetched = is_array($warmed) ? $warmed : null;
396 + $outcome = metasync_process_otto_seo_data($route, false, 0, $prefetched);
137 397
138 - # Step 3: Clear the per-URL cache entry
398 + if ($outcome === Metasync_Otto_Job_Status::OUTCOME_RETRYABLE
399 + || $outcome === Metasync_Otto_Job_Status::OUTCOME_DEFERRED) {
400 + $schedule_retry('outcome_' . $outcome);
401 + return;
402 + }
403 +
404 + if ($outcome !== Metasync_Otto_Job_Status::OUTCOME_SUCCESS
405 + && $outcome !== Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE) {
406 + # Permanent failure: no retry, no purge — caches must keep serving
407 + # the last good content.
408 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'meta', $retry_count, 'permanent:' . $outcome);
409 + return;
410 + }
411 +
412 + # Step 3: meta is current (changed or confirmed unchanged) — clear the
413 + # per-URL host cache entry and re-warm it so OTTO-modified output is
414 + # what gets stored.
415 + $otto_pixel = new Metasync_otto_pixel(false);
139 416 $otto_pixel->refresh_cache($route);
140 417 Metasync_Cache_Purge::purge_single_url($route);
418 + Metasync_Cache_Purge::warm_urls(array($route));
141 419
142 - $urls_to_warm[] = $route;
420 + # A URL that completed on a retry missed its batch's edge purge (the
421 + # batch only waits so long). Late completers purge the edge themselves.
422 + if ($retry_count > 0) {
423 + Metasync_Edge_Cache_Purge::purge(array($route));
424 + }
425 +
426 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_COMPLETED, 'job', $retry_count, $outcome);
427 +
428 + } catch (Exception $e) {
429 + $schedule_retry('exception:' . $e->getMessage());
143 430 }
431 +}
144 432
145 - # Step 4: Re-populate the cache for each affected URL.
146 - # purge_single_url() above cleared WP Rocket + Kinsta per-URL.
147 - # warm_urls() hits each URL now so our code — with fresh transients and post meta —
148 - # is first to populate the host cache. Kinsta stores the OTTO-modified version
149 - # before any real visitor or external bot arrives.
150 - # A full-site purge (purge_all) is intentionally absent: it was nuking the entire
151 - # Kinsta cache for 1–3 URL updates, leaving all other pages cold.
152 - Metasync_Cache_Purge::warm_urls($urls_to_warm);
433 +/**
434 + * Batch handler for the edge CDN purge.
435 + *
436 + * Runs after all per-URL jobs have had time to complete. Per-URL host caches
437 + * are cleared and re-warmed by the individual jobs; this batched event only
438 + * handles the edge CDN purge, which benefits from multi-URL API calls
439 + * (Cloudflare, Fastly, Akamai, Sucuri, Sevella, etc.).
440 + *
441 + * URLs still retrying are stragglers: the batch waits (up to
442 + * METASYNC_OTTO_BATCH_MAX_WAITS reschedules 60s apart) so their freshly
443 + * synced content — not stale content — reaches the edge. URLs that complete
444 + * after the wait budget purge the edge themselves from the retry path.
445 + *
446 + * @param array $routes List of fully-qualified URLs in this batch.
447 + * @param int $wait_count How many times this batch has already waited.
448 + */
449 +function metasync_handle_otto_batch_cache_job($routes = array(), $wait_count = 0) {
450 + // WP-Cron events persist independently of plugin code. A stale or malformed
451 + // event may therefore invoke this callback without its required route list.
452 + // Docblock type isn't enforced at runtime; a stale cron record can pass a non-array.
453 + // @phpstan-ignore-next-line function.alreadyNarrowedType
454 + if (!is_array($routes) || empty($routes)) {
455 + error_log('MetaSync OTTO: skipping batch cache job without valid routes.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
456 + return;
457 + }
153 458
154 - # Step 5: Purge edge CDN caches (Cloudflare, Fastly, Akamai, Sucuri, Sevalla, etc.)
155 - # Tag-based providers purge only the affected posts; full-flush providers fire once per batch.
156 - Metasync_Edge_Cache_Purge::purge($urls_to_warm);
459 + $max_waits = defined('METASYNC_OTTO_BATCH_MAX_WAITS') ? METASYNC_OTTO_BATCH_MAX_WAITS : 3;
460 + // Docblock type isn't enforced at runtime; a stale cron record can pass a non-numeric.
461 + // @phpstan-ignore-next-line function.alreadyNarrowedType
462 + $wait_count = is_numeric($wait_count) ? (int) $wait_count : 0;
157 463
158 - # Track OTTO optimization event in GA4
464 + # Split the batch: terminally-done URLs vs. URLs with work still in
465 + # flight. Unknown-state URLs (legacy events scheduled before the status
466 + # store existed, or deduped duplicates whose entry expired) count as done
467 + # — their per-URL job has either finished or never will, and both mean
468 + # the edge purge should not stall behind them.
469 + $ready = array();
470 + $stragglers = 0;
471 + foreach ($routes as $route) {
472 + $entry = Metasync_Otto_Job_Status::get($route);
473 + $state = is_array($entry) && isset($entry['state']) ? $entry['state'] : null;
474 + if ($state === Metasync_Otto_Job_Status::STATE_RETRYING
475 + || $state === Metasync_Otto_Job_Status::STATE_ACCEPTED) {
476 + $stragglers++;
477 + } else {
478 + # completed / failed / unknown: nothing left to wait for.
479 + $ready[] = $route;
480 + }
481 + }
482 +
483 + # Still live work in the batch and wait budget left: hold the edge purge.
484 + if ($stragglers > 0 && $wait_count < $max_waits) {
485 + metasync_otto_schedule_single_event(time() + 60, 'metasync_process_otto_batch_cache_job', array($routes, $wait_count + 1));
486 + return;
487 + }
488 +
489 + if (empty($ready)) {
490 + return;
491 + }
492 +
159 493 try {
160 - Metasync_GA4::get_instance()->track_otto_optimization($data);
494 + # Purge edge CDN caches (Cloudflare, Fastly, Akamai, Sucuri, Sevalla, etc.)
495 + # Tag-based providers purge only the affected posts; full-flush providers fire once per batch.
496 + Metasync_Edge_Cache_Purge::purge($ready);
161 497 } catch (Exception $e) {
162 - // Analytics tracking failed, continue
498 + metasync_record_failed_action('metasync_process_otto_batch_cache_job');
499 + error_log('MetaSync OTTO: batch cache job failed for ' . count($ready) . ' URLs: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
163 500 }
164 -
165 - # Handle the POST request
166 - return new WP_REST_Response(array(
167 - 'success' => true,
168 - 'message' => 'OTTO crawl notification received',
169 - ), 200);
170 501 }
171 502
172 -# NOTE: Cache system removed - these functions are no longer needed
173 -# Kept for backward compatibility in case old cache directories need cleanup
174 -function metasync_deleteDir($dir) {
175 - if (!is_dir($dir)) {
176 - return false;
503 +/**
504 + * Drainer: work off URLs that could not be scheduled inline (batch overflow
505 + * or a cron that refused the event) from the durable pending queue.
506 + *
507 + * Processes at most 25 URLs per run to stay friendly to WP-Cron, schedules
508 + * the same batched edge purge as the webhook path for the chunk it handled,
509 + * and reschedules itself while the queue is non-empty. URLs it takes off
510 + * the queue are scheduled unconditionally unless a real cron event already
511 + * exists for them — the webhook's overflow bookkeeping must never veto,
512 + * because by then the URL has already left the durable queue.
513 + */
514 +function metasync_handle_otto_pending_drainer() {
515 + $chunk = Metasync_Otto_Job_Status::take_pending(25);
516 + if (empty($chunk)) {
517 + return;
177 518 }
178 - $files = array_diff(scandir($dir), array('.', '..'));
179 - foreach ($files as $file) {
180 - $filePath = $dir . DIRECTORY_SEPARATOR . $file;
181 - if (is_dir($filePath)) {
182 - metasync_deleteDir($filePath);
519 +
520 + $now = time();
521 + $scheduled = 0;
522 + foreach ($chunk as $key => $route) {
523 + # This run owns the chunk: take_pending() already removed these URLs
524 + # from the durable queue, so skipping here would drop them. Only a
525 + # real cron event vetoes — the webhook's overflow ledger row must
526 + # not (see metasync_otto_job_event_pending).
527 + if (metasync_otto_job_event_pending($route)) {
528 + continue;
529 + }
530 + if (metasync_otto_schedule_single_event($now + $key, 'metasync_process_otto_crawl_url_job', array($route))) {
531 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'drain', 0, '');
532 + $scheduled++;
183 533 } else {
184 - unlink($filePath);
534 + # Cron still refusing: put it back at the end of the queue.
535 + Metasync_Otto_Job_Status::enqueue_pending($route);
185 536 }
186 537 }
187 - return rmdir($dir);
538 +
539 + if ($scheduled > 0) {
540 + metasync_otto_schedule_single_event($now + count($chunk) + 5, 'metasync_process_otto_batch_cache_job', array($chunk));
541 + }
542 +
543 + if (Metasync_Otto_Job_Status::pending_count() > 0) {
544 + metasync_otto_schedule_single_event($now + 60, 'metasync_otto_pending_drainer', array());
545 + }
188 546 }
189 547
190 -# Cleanup function for removing old cache directories (if they exist)
191 -function metasync_invalidate_all_caches($folder = ''){
192 - # Cache system removed - this function only exists to clean up old cache directories
193 - if(!defined('WP_CONTENT_DIR')){
194 - return false;
548 +// metasync_is_custom_or_lps_page() now lives in includes/metasync-helpers.php
549 +// (loaded unconditionally before this file) so all SEO surfaces share one rule.
550 +
551 +/**
552 + * Detect an Elementor editor / preview request that OTTO must never process.
553 + *
554 + * Elementor renders its editing canvas in a front-end <iframe> — a logged-in
555 + * GET request that is NOT is_admin(), so the admin/ajax/REST guards in
556 + * metasync_start_otto() do not catch it. If OTTO output-buffers and rewrites
557 + * that response (SimpleHtmlDom in Otto_html_class), the markup the editor's
558 + * JavaScript expects is altered and the canvas stays stuck on "loading" —
559 + * the editor never finishes initialising. The editor surface is for authoring
560 + * only; it is never crawled, so there is nothing for OTTO to optimise there.
561 + *
562 + * Locally this is masked because the OTTO API is unreachable (API_ERROR ⇒ no
563 + * suggestions ⇒ no rewrite); on a live site with real suggestions the rewrite
564 + * happens and the editor breaks. See the "Elementor editor fails to load while
565 + * the plugin is active" report.
566 + *
567 + * @return bool True when the current request is an Elementor editor/preview.
568 + */
569 +function metasync_is_elementor_editor_request() {
570 + # The preview iframe loads the front end with ?elementor-preview=POST_ID.
571 + # This is the request OTTO would otherwise buffer and corrupt.
572 + if (isset($_GET['elementor-preview'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
573 + return true;
195 574 }
196 - $wp_content_dir = WP_CONTENT_DIR;
197 - $cache_dir = $wp_content_dir . '/metasync_caches';
198 - if(in_array($folder, ['posts', 'pages'])){
199 - $cache_dir = $cache_dir . '/' . $folder;
575 +
576 + # Elementor editor / app entry points carried as an action on the front end.
577 + if (isset($_REQUEST['action']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
578 + && in_array($_REQUEST['action'], array('elementor', 'elementor_ajax'), true) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
579 + ) {
580 + return true;
200 581 }
201 - if(is_dir($cache_dir)){
202 - metasync_deleteDir($cache_dir);
582 +
583 + # Authoritative check when Elementor is loaded: its own preview-mode flag.
584 + if (class_exists('\Elementor\Plugin')
585 + && isset(\Elementor\Plugin::$instance->preview)
586 + && is_object(\Elementor\Plugin::$instance->preview)
587 + && method_exists(\Elementor\Plugin::$instance->preview, 'is_preview_mode')
588 + && \Elementor\Plugin::$instance->preview->is_preview_mode()
589 + ) {
590 + return true;
203 591 }
592 +
593 + return false;
204 594 }
205 595
206 596 function metasync_start_otto(){
207 597
@@ -214,13 +604,97 @@
214 604 # SSR is now ENABLED for: single products, product categories, product tags
215 605 # SSR is SKIPPED for: cart, checkout
216 606 # Note: title/description filters (pre_get_document_title, wp_head meta desc)
217 607 # run on ALL pages regardless — they are hooked unconditionally in seo-functions.php
608 +
609 + # ── WooCommerce-independent cart/checkout protection ──────────
610 + # The is_cart()/is_checkout() guards below only recognize WooCommerce. Carts
611 + # from other systems — e.g. the Point of Rental "Catalog" plugin on
612 + # venturarental.com — are invisible to them, so OTTO would process those
613 + # pages and let caching layers store them. A cached cart page corrupts the
614 + # live cart (added items vanish because a stale page is served). The checks
615 + # here do not depend on WooCommerce being active.
616 +
617 + # Never run OTTO on non-GET requests. SSR exists for crawlers, which only
618 + # issue GET; POST/PUT/etc. are form or cart submissions that must pass
619 + # through untouched. (OTTO's own internal fetches use GET.)
620 + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
621 + return;
622 + }
623 +
624 + # Never run OTTO on WordPress core draft/pending previews
625 + # (?p=<ID>&preview=true, with preview_id/preview_nonce on the full link).
626 + # Previews are per-user, non-cacheable, and routed purely by query string —
627 + # and OTTO's route identity ignores the query string, so a processed preview
628 + # is answered with the route's public page (the homepage for /?p=…) instead
629 + # of the draft the editor asked for. Skip early and let WP render it.
218 630 if (
631 + (isset($_GET['preview']) && $_GET['preview'] === 'true') || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
632 + !empty($_GET['preview_id']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
633 + ) {
634 + if (!defined('DONOTCACHEPAGE')) {
635 + define('DONOTCACHEPAGE', true);
636 + }
637 + return;
638 + }
639 +
640 + # Never run OTTO on the Elementor editor/preview iframe. It is a logged-in
641 + # front-end GET (not is_admin()), so it slips past the admin guard below;
642 + # buffering and rewriting it breaks the editor canvas ("fails to load").
643 + if (metasync_is_elementor_editor_request()) {
644 + return;
645 + }
646 +
647 + # WP core file-editor self-check. When an admin saves a THEME file
648 + # via the Plugin/Theme Editor, wp_edit_theme_plugin_file() fires an internal
649 + # loopback GET to home_url('/') carrying wp_scrape_key/wp_scrape_nonce to
650 + # detect a white-screen. That request is NOT is_admin(), so — exactly like
651 + # the Elementor case above — it slips past the admin/AJAX/REST guards below.
652 + # OTTO must not output-buffer or rewrite it: the SimpleHtmlDom pass (and any
653 + # fatal thrown inside the ob_start() callback, e.g. under memory pressure)
654 + # corrupts the scrape and surfaces as the misleading
655 + # "preg_match(): Cannot use output buffering in output buffering display
656 + # handlers" fatal. Skipping OTTO lets WP render the page normally so the
657 + # scrape works. Returning here also avoids the HTTP-fallback path firing a
658 + # second loopback for an already-internal self-request.
659 + if (metasync_is_scrape_request()) {
660 + return;
661 + }
662 +
663 + # Skip OTTO on cart/checkout-type paths regardless of the cart plugin, and
664 + # signal caching plugins (WP Rocket/Kinsta/etc.) to never cache them — a
665 + # cached cart/checkout page is precisely what corrupts cart state.
666 + # Matched against the FIRST path segment (relative to the WP home path) so
667 + # subdirectory installs (/shop/cart) still work, while unrelated content pages
668 + # like /blog/cart or /features/checkout do NOT false-match.
669 + # Drop the query string with explode() (no shared internal pointer like strtok),
670 + # then lower-case and strip surrounding slashes.
671 + $otto_req_path = explode('?', (string) wp_unslash($_SERVER['REQUEST_URI'] ?? ''), 2)[0];
672 + $otto_req_path = strtolower(trim($otto_req_path, '/'));
673 + # Strip the site's base path so first-segment matching works on subdir installs.
674 + $otto_home_path = trim((string) wp_parse_url(home_url(), PHP_URL_PATH), '/');
675 + if ($otto_home_path !== '' && strpos($otto_req_path, $otto_home_path . '/') === 0) {
676 + $otto_req_path = substr($otto_req_path, strlen($otto_home_path) + 1);
677 + }
678 + $otto_first_segment = explode('/', $otto_req_path, 2)[0];
679 + # Filterable so sites can add/remove transactional slugs without code changes.
680 + $otto_cart_segments = apply_filters('metasync_otto_cart_paths', array(
681 + 'cart', 'checkout', 'basket', 'request-a-quote', 'quote-request',
682 + ));
683 + $otto_cart_segments = array_map('strtolower', (array) $otto_cart_segments);
684 + if ($otto_first_segment !== '' && in_array($otto_first_segment, $otto_cart_segments, true)) {
685 + if (!defined('DONOTCACHEPAGE')) {
686 + define('DONOTCACHEPAGE', true);
687 + }
688 + return;
689 + }
690 + # ──────────────────────────────────────────────────────────────────────
691 +
692 + if (
219 693 # disable ajax calls
220 - isset($_GET['ucfrontajaxaction']) ||
694 + isset($_GET['ucfrontajaxaction']) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
221 695 # OTTO Preview mode - skip OTTO when previewing original content
222 - (isset($_GET['otto_preview']) && $_GET['otto_preview'] === '1') ||
696 + (isset($_GET['otto_preview']) && $_GET['otto_preview'] === '1') || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
223 697 # WooCommerce shop archive page only (products and categories now use SSR)
224 698 //(function_exists('is_shop') && is_shop()) ||
225 699 # Cart page
226 700 (function_exists('is_cart') && is_cart()) ||
@@ -232,32 +706,32 @@
232 706 (function_exists('wp_doing_ajax') && wp_doing_ajax()) ||
233 707 # check by constant
234 708 (defined('DOING_AJAX') && DOING_AJAX) ||
235 709 # WooCommerce AJAX endpoint (e.g., ?wc-ajax=update_cart)
236 - (isset($_REQUEST['wc-ajax']) && !empty($_REQUEST['wc-ajax'])) ||
710 + (isset($_REQUEST['wc-ajax']) && !empty($_REQUEST['wc-ajax'])) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
237 711 # AJAX requests via X-Requested-With header
238 712 (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') ||
239 713 # Gravity Forms submission detection - skip OTTO to allow form processing
240 - (isset($_POST['gform_submit']) && (
241 - is_array($_POST['gform_submit']) ||
242 - (is_string($_POST['gform_submit']) && isset($_POST['is_submit_' . $_POST['gform_submit']]) && !empty($_POST['gform_submit']))
714 + (isset($_POST['gform_submit']) && ( // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
715 + is_array($_POST['gform_submit']) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
716 + (is_string($_POST['gform_submit']) && isset($_POST['is_submit_' . $_POST['gform_submit']]) && !empty($_POST['gform_submit'])) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
243 717 )) ||
244 718 # Gravity Forms AJAX submission
245 - (isset($_POST['gform_ajax']) && isset($_POST['gform_submit']) && (
246 - is_array($_POST['gform_submit']) || !empty($_POST['gform_submit'])
719 + (isset($_POST['gform_ajax']) && isset($_POST['gform_submit']) && ( // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
720 + is_array($_POST['gform_submit']) || !empty($_POST['gform_submit']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
247 721 )) ||
248 722 # Gravity Forms file upload
249 - (isset($_POST['gform_uploaded_files'])) ||
723 + (isset($_POST['gform_uploaded_files'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
250 724 # Any Gravity Forms POST parameter
251 - (isset($_POST['gform_submit']) || isset($_POST['gform_unique_id']) || isset($_POST['gform_field_values'])) ||
725 + (isset($_POST['gform_submit']) || isset($_POST['gform_unique_id']) || isset($_POST['gform_field_values'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
252 726 # Formidable Forms AJAX submission detection - skip OTTO to allow form processing
253 - (isset($_POST['action']) && $_POST['action'] === 'frm_entries_create') ||
727 + (isset($_POST['action']) && $_POST['action'] === 'frm_entries_create') || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
254 728 # Formidable Forms POST parameters
255 - (isset($_POST['form_id']) && !empty($_POST['form_id'])) ||
729 + (isset($_POST['form_id']) && !empty($_POST['form_id'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
256 730 # Formidable Forms action parameter
257 - (isset($_POST['frm_action']) && !empty($_POST['frm_action'])) ||
731 + (isset($_POST['frm_action']) && !empty($_POST['frm_action'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
258 732 # Formidable Forms item_key (used in form submissions)
259 - (isset($_POST['item_key']) && !empty($_POST['item_key']))
733 + (isset($_POST['item_key']) && !empty($_POST['item_key'])) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
260 734 ) {
261 735 return;
262 736 }
263 737
@@ -286,8 +760,39 @@
286 760 if (metasync_is_otto_url_manually_excluded($current_url)) {
287 761 return;
288 762 }
289 763
764 + # Skip OTTO for XML endpoints (sitemaps, RSS-as-xml, etc.). OTTO has no
765 + # suggestions for machine-readable XML, and the upstream API returns
766 + # API_ERROR for these URLs — which then stamps misleading
767 + # X-MetaSync-OTTO-Cache: API_ERROR / X-MetaSync-OTTO-Method: NONE headers
768 + # onto otherwise-healthy sitemap responses and causes false-alarm bug
769 + # reports from customers.
770 + $request_path = strtok($request_uri, '?');
771 + if ($request_path && preg_match('#\.xml$#i', $request_path)) {
772 + return;
773 + }
774 +
775 + # Machine-readable core endpoints. Using WordPress's own conditionals rather
776 + # than matching the path: they are exact, and a path pattern here would also
777 + # match ordinary content whose slug merely starts with the same characters
778 + # (e.g. /robots.txt-explained/, /favicon.ico-vs-svg/).
779 + # is_favicon() is WP 5.4+; this plugin supports 5.2.
780 + if (is_robots() || (function_exists('is_favicon') && is_favicon())) {
781 + return;
782 + }
783 +
784 + # Protocol paths and static assets that are never OTTO-able content.
785 + # The guard fails open — if the strategy is unavailable we let the request
786 + # proceed rather than fatal. metasync_otto_class_provides() rather than a
787 + # bare class_exists(): a partially updated install can leave an older copy of
788 + # the strategy class loaded that predates is_non_content_path(), which
789 + # satisfies class_exists() and then fatals on every front-end request.
790 + if (metasync_otto_class_provides('Metasync_Otto_Render_Strategy', 'is_non_content_path')
791 + && Metasync_Otto_Render_Strategy::is_non_content_path($request_path)) {
792 + return;
793 + }
794 +
290 795 # BOT DETECTION: Always detect bots so crawl data reaches the SA backend.
291 796 # Real bots don't execute JS, so otto-tracker.js never fires for them.
292 797 # push_crawl_log_to_sa() fires a non-blocking wp_remote_post here instead.
293 798 $bot_detector = Metasync_Otto_Bot_Detector::get_instance();
@@ -295,8 +800,85 @@
295 800 if ( $detection['is_bot'] ) {
296 801 $bot_detector->push_crawl_log_to_sa( $detection, $current_url );
297 802 }
298 803
804 + # A headless frontend renders the public HTML, so stop after the cheap cache
805 + # protections and crawl telemetry, before any OTTO rendering work begins.
806 + #
807 + # Routed through metasync_headless_is_active() rather than calling the class
808 + # directly: Metasync_Headless_Config resolves only through the committed
809 + # Composer classmap, so a partially updated install can leave this newer
810 + # caller beside a missing or older copy with no fallback lookup path. This
811 + # function is hooked to `wp`, so an unguarded static call is a white screen
812 + # on every front-end page view. The helper treats an unresolvable class as
813 + # "not headless", which keeps OTTO behaving as it did before headless mode.
814 + # @phpstan-ignore-next-line function.alreadyNarrowedType
815 + $metasync_is_headless = function_exists('metasync_headless_is_active')
816 + ? metasync_headless_is_active()
817 + : (class_exists('Metasync_Headless_Config') && Metasync_Headless_Config::is_active());
818 +
819 + if ($metasync_is_headless) {
820 + return;
821 + }
822 +
823 + # Throttle OTTO rendering for bots that are not exempt: at most one render
824 + # per URL+bot every 5 minutes. Humans, search engines (Googlebot, Bingbot,
825 + # Yahoo's Slurp, Sogou, Exabot, Applebot, ...), host cache warmers and
826 + # synthetic performance auditors are never throttled — their hits always get
827 + # full OTTO content, so indexing stays current, the page cache can actually
828 + # be filled, and audit tools measure what a real visitor sees. SEO tools,
829 + # social crawlers, archivers, AI scrapers, uptime monitors and unrecognized
830 + # crawlers are de-duplicated so repeat hits don't re-trigger the expensive
831 + # DOM-rewriting path.
832 + #
833 + # Two complementary gates, deliberately kept separate:
834 + # - is_unthrottled_category() consults the classifier's explicit per-bot
835 + # category map, so the exempt set is defined in exactly one place.
836 + # - metasync_otto_is_unthrottled_infrastructure_agent() matches the raw
837 + # user-agent (plus a query marker and a site filter), which catches
838 + # preload agents that have no entry in the map at all.
839 + if ( $detection['is_bot']
840 + && ! Metasync_Otto_Bot_Detector::is_unthrottled_category( $detection['bot_type'] )
841 + && ! metasync_otto_is_unthrottled_infrastructure_agent( $detection ) ) {
842 + $normalized_url = strtok( $current_url, '?' );
843 + if ( $normalized_url === false ) {
844 + $normalized_url = $current_url;
845 + }
846 + // Lowercase + length cap so attacker-controlled bot_name variations
847 + // (EvilBota, EvilBotB, EvilBotc, ...) collapse to a bounded key space
848 + // and cannot flood wp_options with unique transient rows.
849 + $bot_name = substr( strtolower( $detection['bot_name'] ?? 'unknown' ), 0, 32 );
850 + $render_throttle_key = 'metasync_otto_rendered_' . md5( $normalized_url . '|' . $bot_name );
851 + if ( get_transient( $render_throttle_key ) ) {
852 + // Prevent page caches (WP Rocket, W3TC, etc.) from saving the
853 + // un-OTTO'd response and serving it to subsequent human visitors.
854 + if ( ! defined( 'DONOTCACHEPAGE' ) ) {
855 + define( 'DONOTCACHEPAGE', true );
856 + }
857 + return;
858 + }
859 + set_transient( $render_throttle_key, 1, 5 * MINUTE_IN_SECONDS );
860 + }
861 +
862 + # Skip OTTO on 404s.
863 + #
864 + # OTTO suggestions are keyed to real, crawled pages. Asking the API about a URL
865 + # that returns 404 spends a full API_TIMEOUT of PHP-FPM worker time on a
866 + # guaranteed-empty answer. Not theoretical: in one production sample, 21 of 54
867 + # crawler requests were 404s on URLs that never existed on the site, each
868 + # costing ~2s of a worker.
869 + #
870 + # Deliberately placed AFTER bot detection so push_crawl_log_to_sa() still
871 + # reports the crawl to SearchAtlas — that telemetry is how the crawler side
872 + # can learn to stop requesting these URLs. Only the expensive suggestions
873 + # lookup is skipped.
874 + #
875 + # Filterable because "should a 404 page get OTTO treatment" is arguably a site
876 + # decision, even though there is no known case where it should.
877 + if (is_404() && apply_filters('metasync_otto_skip_on_404', true)) {
878 + return;
879 + }
880 +
299 881 # Optionally skip OTTO processing for bot traffic (when the setting is enabled)
300 882 if ($bot_detector->should_skip_otto()) {
301 883 // Log the bot locally and count the saved API call
302 884 $bot_detector->log_detection($detection);
@@ -315,13 +897,44 @@
315 897 return; # Exit early, Otto is disabled when WP Rocket is active
316 898 }
317 899 }
318 900
901 + # Skip OTTO for Divi AJAX pagination and paginated archive requests.
902 + # ?et_blog = Divi AJAX pagination callback
903 + # /page/N/ = paginated blog/archive pages — OTTO's buffer/HTTP render causes
904 + # module numbering mismatch between page 1 (with TB template) and page N
905 + # (without TB template), breaking Divi's JS pagination selector matching.
906 + if (isset($_GET['et_blog']) || (is_paged() && !is_singular())) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
907 + return;
908 + }
909 +
910 + # Skip OTTO on search-results pages. A search request carries its
911 + # meaning entirely in the query string (e.g. /?s=term, or FiboSearch's
912 + # /?s=term&post_type=product&dgwt_wcas=1). get_route() intentionally strips
913 + # the query string to build a canonical, cache-key-stable route — which
914 + # collapses a query-only search URL to the site root ('/'). OTTO then finds
915 + # the HOME page's suggestions for that route and, on the HTTP render path,
916 + # serves the home page in place of the search results (the address bar keeps
917 + # the search URL — it is a silent content swap, not a redirect). This only
918 + # surfaces for logged-in / uncached visitors, since page caches serve the
919 + # correct pre-rendered search page to everyone else. Search-results pages
920 + # have no per-canonical-URL OTTO suggestions of their own, so OTTO must never
921 + # run here. The raw $_GET['s'] check is a defensive fallback for setups where
922 + # the main query is altered before this point.
923 + if (is_search() || !empty($_GET['s'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
924 + return;
925 + }
926 +
319 927 # Handle cache plugin compatibility early - before any caching happens
320 928 metasync_otto_handle_cache_compatibility();
321 929
322 930 # check if OTTO is disabled for this specific page/post
323 - $post_id = get_the_ID();
931 + # Gate on is_singular() for the same reason the custom/LPS check below does:
932 + # on an archive, search or term query get_the_ID() returns the first post of
933 + # the loop — WP::register_globals() seeds $GLOBALS['post'] with it — not the
934 + # page being rendered. Without the gate, one excluded or per-page-disabled
935 + # post silently takes OTTO off every archive that happens to list it first.
936 + $post_id = is_singular() ? ( get_queried_object_id() ?: get_the_ID() ) : 0;
324 937 if ($post_id && class_exists('Metasync_Otto_Frontend_Toolbar')) {
325 938 if (Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id)) {
326 939 return;
327 940 }
@@ -326,16 +939,51 @@
326 939 return;
327 940 }
328 941 }
329 942
943 + # Skip OTTO on MetaSync custom HTML / LPS-imported pages — they ship their
944 + # own complete, self-contained SEO and OTTO must not inject or overwrite it
945 + # with a different/older project's SEO. Resolve the queried object
946 + # id (with get_the_ID() fallback) so the static-front-page case — an LPS home
947 + # set as the WP front page, where is_page() is false — is still detected.
948 + # Applying the skip here, upstream of the single render_route_html() entry,
949 + # covers all three render paths (Rocket buffer, output buffer, HTTP fallback).
950 + # Only singular views (posts/pages, incl. a static front page) can be a
951 + # custom/LPS page; gate on is_singular() so an archive/search/term query
952 + # can never have its object id mistaken for a custom page's post id.
953 + $custom_page_id = is_singular() ? ( get_queried_object_id() ?: get_the_ID() ) : 0;
954 + if (metasync_is_custom_or_lps_page($custom_page_id)) {
955 + if (!headers_sent() && Metasync_Otto_Render_Strategy::diagnostics_enabled()) {
956 + header('X-MetaSync-OTTO-Method: EXCLUDED');
957 + }
958 + return;
959 + }
960 +
330 961 # check if we are having an otto request
331 - if(!empty($_GET['is_otto_page_fetch'])){
962 + #
963 + # Recognise our own internal fetch by header/User-Agent as well as by the
964 + # query parameter. A redirect that rebuilds the URL strips the parameter,
965 + # and without this the redirected request re-enters OTTO and fires another
966 + # internal fetch - an unbounded self-request loop.
967 + # method_exists() is redundant to static analysis - this MR adds the method,
968 + # so PHPStan proves the call always true. It is kept for the upgrade window:
969 + # during a plugin update an opcache can still hold the previous
970 + # Metasync_Otto_Render_Strategy, where class_exists() passes but the method
971 + # is absent, and an unguarded static call would fatal the page.
972 + # @phpstan-ignore-next-line function.alreadyNarrowedType
973 + $otto_has_fetch_detector = class_exists('Metasync_Otto_Render_Strategy') && method_exists('Metasync_Otto_Render_Strategy', 'is_internal_fetch');
332 974
975 + $otto_is_internal_fetch = $otto_has_fetch_detector
976 + ? Metasync_Otto_Render_Strategy::is_internal_fetch()
977 + : !empty($_GET['is_otto_page_fetch']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only loopback marker, request-scoped filters only
978 +
979 + if($otto_is_internal_fetch){
980 +
333 981 # Block SEO plugins NOW for this internal fetch request
334 982 # metasync_otto_block_seo_plugins();
335 983 # $_SERVER['REQUEST_URI'] = remove_query_arg('is_otto_page_fetch', $_SERVER['REQUEST_URI']);
336 - $block_title = !empty($_GET['otto_block_title']) && $_GET['otto_block_title'] === '1';
337 - $block_description = !empty($_GET['otto_block_desc']) && $_GET['otto_block_desc'] === '1';
984 + $block_title = !empty($_GET['otto_block_title']) && $_GET['otto_block_title'] === '1'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only loopback marker, request-scoped filters only
985 + $block_description = !empty($_GET['otto_block_desc']) && $_GET['otto_block_desc'] === '1'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only loopback marker, request-scoped filters only
338 986
339 987 # Block SEO plugins conditionally based on what Otto has
340 988 if ($block_title || $block_description) {
341 989 metasync_otto_block_seo_plugins($block_title, $block_description);
@@ -371,8 +1019,19 @@
371 1019
372 1020 # get the otto uuid
373 1021 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
374 1022
1023 + # Upgrade-window guard: the bundled DOM parser resolves only through the
1024 + # otto/vendor autoloader required at the top of this file. While an update
1025 + # is mid-copy (or a stale opcache classmap is still resident), that
1026 + # dependency can briefly be undefined here, and constructing the render
1027 + # stack below would throw a class-not-found Error and take the whole page
1028 + # down with it. Degrade to an OTTO-less page for this request instead —
1029 + # the next request, on the settled tree, renders normally.
1030 + if ( ! class_exists( 'simplehtmldom\HtmlDocument' ) ) {
1031 + return;
1032 + }
1033 +
375 1034 # start the class
376 1035 $otto = new Metasync_otto_pixel($otto_uuid);
377 1036
378 1037 # call render
@@ -384,12 +1043,13 @@
384 1043 * Controls DONOTCACHEPAGE constant based on active plugins and configuration
385 1044 * This function is called early in the WordPress lifecycle
386 1045 */
387 1046 function metasync_otto_handle_cache_compatibility() {
388 - # Detect active plugins
1047 + # Detect active plugins. SG Optimizer detection now lives in
1048 + # metasync_otto_disable_sg_page_cache(), which is invoked later
1049 + # once OTTO confirms suggestions for the URL.
389 1050 $brizy_active = class_exists('Brizy_Editor') || defined('BRIZY_VERSION');
390 1051 $wp_rocket_active = class_exists('WP_Rocket');
391 - $sg_optimizer_active = is_plugin_active('sg-cachepress/sg-cachepress.php');
392 1052
393 1053 # OPTIMIZED: Get configuration option using cached config
394 1054 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
395 1055
@@ -403,9 +1063,9 @@
403 1063 if ($cached !== false) {
404 1064 $has_brizy_posts = ($cached === 'yes');
405 1065 } else {
406 1066 # Query database only if cache missed
407 - $has_brizy_posts = $wpdb->get_var(
1067 + $has_brizy_posts = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- feature-detection probe for Brizy posts — postmeta flag scan has no WordPress API
408 1068 "SELECT COUNT(*) FROM {$wpdb->postmeta}
409 1069 WHERE meta_key = 'brizy_post_uid'
410 1070 AND meta_value != ''
411 1071 LIMIT 1"
@@ -419,51 +1079,383 @@
419 1079
420 1080 # Determine if DONOTCACHEPAGE should be set
421 1081 $should_set_donotcachepage = false;
422 1082
423 - # Case 1: Brizy is active with posts - always needed
1083 + # Case 1: Brizy is active with posts - always needed. Brizy pages are
1084 + # dynamically rendered regardless of OTTO, so caching must be disabled
1085 + # whenever Brizy posts exist.
424 1086 if ($brizy_active && !empty($has_brizy_posts)) {
425 1087 $should_set_donotcachepage = true;
426 1088 }
427 1089
428 - # Case 2: SG Optimizer without WP Rocket - prevent conflicts
429 - elseif ($sg_optimizer_active && !$wp_rocket_active) {
430 - $should_set_donotcachepage = true;
431 - }
432 -
433 - # Case 3: User explicitly disabled Otto for WP Rocket compatibility
1090 + # Case 2: User explicitly disabled Otto for WP Rocket compatibility
434 1091 elseif ($wp_rocket_active && $wp_rocket_compat_mode === 'disable_otto') {
435 1092 $should_set_donotcachepage = true;
436 1093 return; # Exit early, Otto won't run
437 1094 }
438 1095
439 - # Case 4: WP Rocket active with auto/buffer mode - DON'T set DONOTCACHEPAGE
1096 + # Case 3: WP Rocket active with auto/buffer mode - DON'T set DONOTCACHEPAGE
440 1097 # This allows WP Rocket optimizations to continue working
441 1098
1099 + # NOTE: SiteGround SG Optimizer cache bypass is intentionally NOT
1100 + # handled here. Emitting no-cache headers / disabling SG cache on this hook
1101 + # fired on EVERY front-end page — including pages OTTO never modifies —
1102 + # forcing SG to bypass its page cache site-wide and spiking CPU. The SG
1103 + # bypass is now deferred to metasync_otto_disable_sg_page_cache(), called
1104 + # from render_route_html() only after OTTO confirms it has suggestions for
1105 + # the current URL.
1106 +
442 1107 # Only set DONOTCACHEPAGE if needed
443 1108 if ($should_set_donotcachepage && !defined('DONOTCACHEPAGE')) {
444 1109 define('DONOTCACHEPAGE', true);
445 1110 }
1111 +}
446 1112
447 - # Apply SG Optimizer specific filters only if SG is active and no WP Rocket
448 - if ($sg_optimizer_active && !$wp_rocket_active && $should_set_donotcachepage) {
449 - if (!defined('SG_CachePress_SUPERCACHER')) {
450 - define('SG_CachePress_SUPERCACHER', false);
1113 +/**
1114 + * Disable SiteGround SG Optimizer page caching for the CURRENT request only.
1115 + *
1116 + * Called from Metasync_otto_pixel::render_route_html() once OTTO has confirmed
1117 + * it has suggestions to apply to the requested URL. Scoping the no-cache
1118 + * override to pages OTTO actually modifies — rather than emitting it on every
1119 + * front-end page via the unconditional `wp` hook — keeps SG's page cache
1120 + * working site-wide and avoids the CPU spike reported in.
1121 + *
1122 + * Only meaningful on SiteGround sites without WP Rocket; a no-op otherwise.
1123 + */
1124 +function metasync_otto_disable_sg_page_cache() {
1125 + # Only relevant when SG Optimizer is active and WP Rocket is not present.
1126 + if (class_exists('WP_Rocket')) {
1127 + return;
1128 + }
1129 + if (!is_plugin_active('sg-cachepress/sg-cachepress.php')) {
1130 + return;
1131 + }
1132 +
1133 + if (!defined('DONOTCACHEPAGE')) {
1134 + define('DONOTCACHEPAGE', true);
1135 + }
1136 + if (!defined('SG_CachePress_SUPERCACHER')) {
1137 + define('SG_CachePress_SUPERCACHER', false);
1138 + }
1139 +
1140 + add_filter('sgo_html_cache_disable', '__return_true', 999);
1141 + add_filter('sgo_css_combine_exclude', '__return_true', 999);
1142 + add_filter('sgo_js_combine_exclude', '__return_true', 999);
1143 + add_filter('sgo_cache_this_page', '__return_false', 999);
1144 +
1145 + if (!headers_sent()) {
1146 + header('Cache-Control: no-cache, must-revalidate, max-age=0');
1147 + header('X-Accel-Expires: 0');
1148 + }
1149 +}
1150 +
1151 +/**
1152 + * How long a response that OTTO could not optimize may be cached, in seconds.
1153 + *
1154 + * Deliberately short rather than zero — see
1155 + * metasync_otto_limit_response_cache() for why.
1156 + */
1157 +if (!defined('METASYNC_OTTO_FALLBACK_CACHE_TTL')) {
1158 + define('METASYNC_OTTO_FALLBACK_CACHE_TTL', 60);
1159 +}
1160 +
1161 +/**
1162 + * Cap how long the current response may be cached, because OTTO could not apply
1163 + * its suggestions to it.
1164 + *
1165 + * OTTO fails open: when suggestions are unavailable, or when the HTML rewrite
1166 + * fails, the site's own un-optimized markup is served instead. That markup is
1167 + * valid — the harm is that a caching layer then stores it and serves it to
1168 + * everyone for the rest of its TTL (a host edge cache with s-maxage=86400 pins
1169 + * it for a day), which surfaces to customers as the OTTO title "reverting".
1170 + *
1171 + * A short TTL is used rather than `no-store`, which is the obvious choice but the
1172 + * wrong one. Some failures look transient and are not: a page that always breaks
1173 + * the rewriter, or a host that cannot fetch its own pages, fails identically on
1174 + * every request. `no-store` would make those permanently uncacheable, sending
1175 + * 100% of their traffic to the origin forever in exchange for markup that will
1176 + * never improve. A one-minute ceiling fixes the reported bug just as completely —
1177 + * a day-long pin becomes a minute — while capping the cost of being wrong about
1178 + * which failures recover.
1179 + *
1180 + * Different layers read different signals, so all of them are emitted:
1181 + *
1182 + * - Cache-Control CDNs, host edge caches, browsers. `max-age=0` keeps
1183 + * browsers revalidating while `s-maxage` lets shared
1184 + * caches absorb a burst for the ceiling's duration.
1185 + * - X-Accel-Expires Nginx's own directive. Evaluated with higher
1186 + * precedence than Cache-Control, and not covered by
1187 + * the `fastcgi_ignore_headers Cache-Control Expires`
1188 + * recipe common on managed hosts — so it can survive
1189 + * a config that discards Cache-Control. Nginx strips
1190 + * X-Accel-* before the response reaches the client,
1191 + * so there is no visitor-facing side effect.
1192 + * - Surrogate-Control Varnish and Fastly. Note that on stock Varnish the
1193 + * mere presence of this header suppresses its
1194 + * Cache-Control check, so it must carry a real
1195 + * lifetime rather than be left at some other value.
1196 + * - DONOTCACHEPAGE WordPress-level page caches (WP Rocket, W3TC,
1197 + * LiteSpeed, SG Optimizer). These run inside PHP and
1198 + * never see response headers. They have no notion of a
1199 + * short TTL — it is cache or do not — so they skip
1200 + * entirely. That is safe here because callers only
1201 + * invoke this for failures expected to resolve; a
1202 + * permanent one (an oversized page, a host that cannot
1203 + * reach itself) is left fully cacheable by the caller
1204 + * and never reaches this function.
1205 + *
1206 + * Cache-tagging headers (Surrogate-Key, Cache-Tag, Edge-Cache-Tag) are
1207 + * deliberately left in place. They are purge-targeting labels, not cacheability
1208 + * directives — no CDN stores a response *because* a tag is present. On a layer
1209 + * that stores this response anyway, those tags are the only handle a later purge
1210 + * has on it, so removing them would discard the recovery path exactly where it
1211 + * is needed most.
1212 + *
1213 + * Some layers may honour none of this; a managed host that overrides origin
1214 + * cache headers will cache the response regardless. This bounds the damage where
1215 + * it can and is inert where it cannot.
1216 + *
1217 + * @param string $reason Short machine-readable reason, surfaced as a response
1218 + * header for debugging (e.g. 'API_ERROR', 'RENDER_FAILED').
1219 + */
1220 +/**
1221 + * Build the response headers that cap how long an un-optimized response may be
1222 + * cached.
1223 + *
1224 + * Thin wrapper over Metasync_Otto_Render_Strategy::fallback_cache_headers(), which
1225 + * owns the header set so it can be unit-tested (this file cannot be loaded in a
1226 + * test context). Returns an empty array if the strategy class is somehow absent,
1227 + * so a caller emits nothing rather than an untested divergent copy.
1228 + *
1229 + * @param int $ttl Cache ceiling in seconds.
1230 + * @param string $reason Optional machine-readable reason for the debug header.
1231 + * @return array Header name => value.
1232 + */
1233 +function metasync_otto_fallback_cache_headers($ttl, $reason = '') {
1234 + if (!class_exists('Metasync_Otto_Render_Strategy')) {
1235 + return [];
1236 + }
1237 +
1238 + return Metasync_Otto_Render_Strategy::fallback_cache_headers_for_response($ttl, $reason);
1239 +}
1240 +
1241 +function metasync_otto_limit_response_cache($reason = '') {
1242 + # This function must only ever make the response LESS cacheable. header()
1243 + # replaces by default and the capped set carries X-Accel-Expires, which nginx
1244 + # evaluates ahead of Cache-Control — so emitting it unconditionally could
1245 + # override an upstream no-store and grant edge caching that nothing had
1246 + # granted. The strategy reconciles existing cache directives before emitting
1247 + # a capped set.
1248 +
1249 + $strategy_available = class_exists('Metasync_Otto_Render_Strategy');
1250 +
1251 + # 1. Logged-in users. OTTO renders for them unless the "disable on logged in"
1252 + # setting is switched on, which is off by default — so this is the common
1253 + # case, not an edge case. Their page may carry session-specific or
1254 + # membership-gated content, and capping at 60 seconds would still let a
1255 + # shared cache store and re-serve it to somebody else.
1256 + if (function_exists('is_user_logged_in') && is_user_logged_in()) {
1257 + if (!headers_sent() && $strategy_available) {
1258 + foreach (Metasync_Otto_Render_Strategy::no_store_headers($reason) as $name => $value) {
1259 + header($name . ': ' . $value);
1260 + }
451 1261 }
1262 + if (!defined('DONOTCACHEPAGE')) {
1263 + define('DONOTCACHEPAGE', true);
1264 + }
1265 + return;
1266 + }
452 1267
453 - add_filter('sgo_html_cache_disable', '__return_true', 999);
454 - add_filter('sgo_css_combine_exclude', '__return_true', 999);
455 - add_filter('sgo_js_combine_exclude', '__return_true', 999);
456 - add_filter('sgo_cache_this_page', '__return_false', 999);
1268 + # 2. Something upstream already restricted this response. Leave its headers
1269 + # alone — a page deliberately marked no-store must not be handed a 60
1270 + # second shared-cache lifetime by us.
1271 + # The strategy turns an existing no-store into a consistent no-store set and
1272 + # uses the strictest known existing lifetime for every cache layer.
1273 + $ttl = (int) METASYNC_OTTO_FALLBACK_CACHE_TTL;
457 1274
458 - if (!headers_sent()) {
459 - header('Cache-Control: no-cache, must-revalidate, max-age=0');
460 - header('X-Accel-Expires: 0');
1275 + if (!headers_sent()) {
1276 + foreach (metasync_otto_fallback_cache_headers($ttl, $reason) as $name => $value) {
1277 + header($name . ': ' . $value);
461 1278 }
462 1279 }
1280 +
1281 + # Reaches page-cache plugins, which do not see the headers above.
1282 + if (!defined('DONOTCACHEPAGE')) {
1283 + define('DONOTCACHEPAGE', true);
1284 + }
463 1285 }
464 1286
465 1287 /**
1288 + * Report an OTTO render failure to the local error log and to Sentry.
1289 + *
1290 + * These failures were invisible: the render path swallowed them and returned the
1291 + * original HTML, so nobody knew how often they fired. Reporting them is what makes
1292 + * a persistently-failing URL discoverable instead of silently costing origin work.
1293 + *
1294 + * Two constraints shape this:
1295 + *
1296 + * 1. It runs on the visitor render path, so it must not add latency. The Sentry
1297 + * report is therefore queued here and sent by
1298 + * metasync_otto_flush_render_failure_reports() after the response has been
1299 + * handed to the visitor, so the cost is invisible rather than merely small.
1300 + *
1301 + * Two alternatives were tried and rejected. Sending inline with a blocking
1302 + * transport — cURL with a 5s connect and 5s read timeout — could add ten
1303 + * seconds to a page load, precisely when the request is already degraded.
1304 + * Sending inline non-blocking is far cheaper but still not free: opening the
1305 + * connection and writing the body costs the visitor something.
1306 + *
1307 + * A WP-Cron single event was the other obvious option and is worse than both:
1308 + * every scheduled event is stored in the `cron` option, which is autoloaded on
1309 + * every request site-wide, and WP's own 10-minute duplicate suppression keys on
1310 + * the serialized args — which carry per-request byte counts here, so it would
1311 + * never engage. A site with many failing URLs would grow that option
1312 + * unboundedly and pay for it on every page load, which is the cost the deferral
1313 + * was meant to avoid in the first place.
1314 + *
1315 + * The local log stays synchronous. It is cheaper than Sentry but not free —
1316 + * Metasync_Error_Logger::log() rewrites a summary option on each call — which is
1317 + * the second reason for the throttle below.
1318 + *
1319 + * 2. A failing page can be requested thousands of times an hour, so reports are
1320 + * throttled per reason+URL.
1321 + *
1322 + * @param string $reason Machine-readable failure reason (e.g. 'RENDER_EXCEPTION').
1323 + * @param string $message Human-readable description.
1324 + * @param array $context Extra key/value context.
1325 + * @param string $level Sentry level: 'error' or 'warning'.
1326 + */
1327 +function metasync_otto_report_render_failure($reason, $message, $context = [], $level = 'error') {
1328 + $url = '';
1329 + if (!empty($_SERVER['HTTP_HOST']) && !empty($_SERVER['REQUEST_URI'])) {
1330 + $url = (is_ssl() ? 'https://' : 'http://')
1331 + . $_SERVER['HTTP_HOST']
1332 + . strtok(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), '?');
1333 + }
1334 +
1335 + # Throttle: at most one report per reason+URL per 5 minutes. Requests without a
1336 + # resolvable URL (CLI, odd SAPIs) collapse onto one key, which is intended —
1337 + # they cannot be attributed to a page anyway.
1338 + $throttle_key = 'metasync_otto_fail_' . md5($reason . '|' . $url);
1339 + if (get_transient($throttle_key)) {
1340 + return;
1341 + }
1342 + set_transient($throttle_key, 1, 5 * MINUTE_IN_SECONDS);
1343 +
1344 + $context = array_merge([
1345 + 'reason' => $reason,
1346 + 'url' => $url,
1347 + ], $context);
1348 +
1349 + # Local structured log. Stays on the customer's site, so it is useful to
1350 + # support but not visible to us — hence the Sentry dispatch below.
1351 + if (class_exists('Metasync_Error_Logger')) {
1352 + Metasync_Error_Logger::log(
1353 + Metasync_Error_Logger::CATEGORY_OTTO_RENDER,
1354 + $level === 'warning'
1355 + ? Metasync_Error_Logger::SEVERITY_WARNING
1356 + : Metasync_Error_Logger::SEVERITY_ERROR,
1357 + $message,
1358 + $context
1359 + );
1360 + }
1361 +
1362 + # Sentry. Queued rather than sent here: even a non-blocking dispatch costs the
1363 + # visitor something (opening the connection and writing the body), and this runs
1364 + # on a request that is already degraded. The queue is flushed after the response
1365 + # has been handed to the visitor — see metasync_otto_flush_render_failure_reports().
1366 + if (!function_exists('metasync_sentry_capture_message_nonblocking')) {
1367 + return;
1368 + }
1369 +
1370 + $GLOBALS['metasync_otto_pending_failure_reports'][] = [
1371 + 'message' => $message,
1372 + 'level' => $level,
1373 + 'context' => $context,
1374 + ];
1375 +
1376 + if (empty($GLOBALS['metasync_otto_failure_flush_hooked'])) {
1377 + $GLOBALS['metasync_otto_failure_flush_hooked'] = true;
1378 + # PHP_INT_MAX so this runs after everything else registered on shutdown —
1379 + # WordPress flushes its own output buffers and closes the object cache at the
1380 + # default priority, and this handler ends output for the request.
1381 + add_action('shutdown', 'metasync_otto_flush_render_failure_reports', PHP_INT_MAX);
1382 + }
1383 +}
1384 +
1385 +/**
1386 + * Send any queued render-failure reports after the response has been delivered.
1387 + *
1388 + * fastcgi_finish_request() (PHP-FPM) and litespeed_finish_request() (LiteSpeed)
1389 + * flush the response and close the connection to the visitor while leaving PHP
1390 + * running. Calling one of them here means the page is already on its way before
1391 + * any telemetry work starts, so the reporting cost is invisible to the visitor
1392 + * rather than merely small.
1393 + *
1394 + * Neither exists under mod_php or the CLI SAPI. There the reports are still sent
1395 + * at the very end of the request, after all page output has been generated and
1396 + * flushed — the same ordering, just without the connection being closed first.
1397 + *
1398 + * The dispatch itself stays non-blocking. Now that the visitor is served, the only
1399 + * remaining cost is PHP worker occupancy, and a blocking transport with a 5s
1400 + * connect and 5s read timeout could tie up a worker for ten seconds per report on
1401 + * a site that is failing often.
1402 + */
1403 +function metasync_otto_flush_render_failure_reports() {
1404 + $reports = $GLOBALS['metasync_otto_pending_failure_reports'] ?? [];
1405 + $GLOBALS['metasync_otto_pending_failure_reports'] = [];
1406 +
1407 + if (empty($reports)) {
1408 + return;
1409 + }
1410 +
1411 + if (function_exists('fastcgi_finish_request')) {
1412 + fastcgi_finish_request();
1413 + } elseif (function_exists('litespeed_finish_request')) {
1414 + litespeed_finish_request();
1415 + }
1416 +
1417 + if (!function_exists('metasync_sentry_capture_message_nonblocking')) {
1418 + return;
1419 + }
1420 +
1421 + foreach ($reports as $report) {
1422 + try {
1423 + metasync_sentry_capture_message_nonblocking(
1424 + $report['message'],
1425 + $report['level'],
1426 + $report['context']
1427 + );
1428 + } catch (Exception $e) {
1429 + # Telemetry must never be able to break a request.
1430 + } catch (Error $e) {
1431 + # Same.
1432 + }
1433 + }
1434 +}
1435 +
1436 +/**
1437 + * Whether MetaSync is still emitting Open Graph / Twitter tags.
1438 + *
1439 + * The Social Media & Open Graph switch stops MetaSync writing those tags on
1440 + * every render path, so the suppression below has to stand down with it —
1441 + * removing another plugin's og:/twitter: tag while contributing none of our own
1442 + * would leave the page with no social tags at all.
1443 + *
1444 + * The class_exists() guard keeps this usable if the flags file has not loaded
1445 + * yet; treating that window as "enabled" preserves the previous behaviour.
1446 + *
1447 + * @return bool
1448 + */
1449 +function metasync_otto_social_output_enabled() {
1450 + if (!class_exists('Metasync_Feature_Flags')) {
1451 + return true;
1452 + }
1453 +
1454 + return Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::SOCIAL_OG);
1455 +}
1456 +
1457 +/**
466 1458 * Block SEO plugins conditionally based on what Otto is providing
467 1459 * Only blocks title if Otto has title, only blocks description if Otto has description
468 1460 * This prevents duplicate SEO tags while allowing fallback to SEO plugins when Otto has no data.
469 1461 * Supports Yoast SEO, Rank Math, and AIOSEO (free + pro).
@@ -503,11 +1495,19 @@
503 1495
504 1496
505 1497 # Remove description presenters only when OTTO has a description
506 1498 if ($block_description) {
1499 + # The plain meta description belongs to the SEO title/description
1500 + # feature, which has its own setting.
507 1501 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Meta_Description_Presenter';
508 - $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Open_Graph\Description_Presenter';
509 - $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Twitter\Description_Presenter';
1502 +
1503 + # The OG/Twitter descriptions are social tags. With that feature
1504 + # switched off MetaSync writes none of its own, so Yoast's must be
1505 + # left alone rather than removed with nothing to replace them.
1506 + if (metasync_otto_social_output_enabled()) {
1507 + $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Open_Graph\Description_Presenter';
1508 + $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Twitter\Description_Presenter';
1509 + }
510 1510 }
511 1511
512 1512 foreach ($presenters as $key => $presenter) {
513 1513 // Safely get class name, suppressing autoload errors
@@ -525,11 +1525,13 @@
525 1525 # Disable Rank Math
526 1526 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
527 1527 is_plugin_active('seo-by-rankmath/rank-math.php')) {
528 1528
529 - if ($block_title) {
530 - add_filter('rank_math/frontend/title', '__return_empty_string', 999);
531 - }
1529 + # TITLE: Never blank Rank Math's title output during SSR fetch.
1530 + # rank_math/frontend/title also gates Rank Math's og:title/twitter:title
1531 + # output, so forcing it empty blanks those social tags along with the
1532 + # document title. Let Rank Math render its own title/og/twitter tags;
1533 + # OTTO's replace_title() and the dedup/precedence pass clean up duplicates.
532 1534
533 1535 if ($block_description) {
534 1536 add_filter('rank_math/frontend/description', '__return_false', 999);
535 1537 add_filter('rank_math/frontend/show_keywords', '__return_false', 999);
@@ -540,17 +1542,22 @@
540 1542 if (is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
541 1543 is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
542 1544
543 1545 if ($block_title) {
1546 + # The document title belongs to the SEO title feature, which has its
1547 + # own setting; only the og:/twitter: unsets below are social.
544 1548 add_filter('aioseo_title', '__return_empty_string', 999);
545 - add_filter('aioseo_facebook_tags', function($meta) {
546 - if (is_array($meta)) { unset($meta['og:title']); }
547 - return $meta;
548 - }, 999);
549 - add_filter('aioseo_twitter_tags', function($meta) {
550 - if (is_array($meta)) { unset($meta['twitter:title']); }
551 - return $meta;
552 - }, 999);
1549 +
1550 + if (metasync_otto_social_output_enabled()) {
1551 + add_filter('aioseo_facebook_tags', function($meta) {
1552 + if (is_array($meta)) { unset($meta['og:title']); }
1553 + return $meta;
1554 + }, 999);
1555 + add_filter('aioseo_twitter_tags', function($meta) {
1556 + if (is_array($meta)) { unset($meta['twitter:title']); }
1557 + return $meta;
1558 + }, 999);
1559 + }
553 1560 }
554 1561
555 1562 if ($block_description) {
556 1563 # Use granular tag list when available to only block what OTTO provides
@@ -555,10 +1562,12 @@
555 1562 if ($block_description) {
556 1563 # Use granular tag list when available to only block what OTTO provides
557 1564 $tags = !empty($description_tags) ? $description_tags : [];
558 1565 $block_standard = empty($tags) || in_array('meta[name=description]', $tags);
559 - $block_og_desc = empty($tags) || in_array('meta[property=og:description]', $tags);
560 - $block_tw_desc = empty($tags) || in_array('meta[name=twitter:description]', $tags);
1566 + # Social tags only get blocked while MetaSync is still emitting them.
1567 + $social_enabled = metasync_otto_social_output_enabled();
1568 + $block_og_desc = $social_enabled && (empty($tags) || in_array('meta[property=og:description]', $tags));
1569 + $block_tw_desc = $social_enabled && (empty($tags) || in_array('meta[name=twitter:description]', $tags));
561 1570
562 1571 if ($block_standard) {
563 1572 add_filter('aioseo_description', '__return_empty_string', 999);
564 1573 }
@@ -576,13 +1585,21 @@
576 1585 }
577 1586 }
578 1587 }
579 1588 }
580 -# check that otto is not added via js to the site
1589 +# Check whether OTTO is ALSO injected via JavaScript on the site (a misconfiguration
1590 +# we warn admins about).
1591 +#
1592 +# The actual detection makes a loopback HTTP request to the site's OWN url. On hosts
1593 +# that disallow same-server loopback (e.g. SiteGround), that request blocks for the
1594 +# full timeout and then fails — and when it runs inline on admin_notices it makes
1595 +# every wp-admin page hang. It must therefore NEVER run inline on an admin request.
1596 +#
1597 +# metasync_check_otto_js() is now read-only: it returns the cached result and, on a
1598 +# cache miss, schedules a one-off BACKGROUND job (WP-Cron) to compute it. The notice
1599 +# simply shows nothing until the background result is available.
581 1600 function metasync_check_otto_js(){
582 1601
583 - # Cache result to avoid a blocking HTTP self-request (~2.4s)
584 - # on every single admin page load.
585 1602 $cache_key = 'metasync_otto_js_detected';
586 1603 $cached = get_transient($cache_key);
587 1604
588 1605 if ($cached !== false) {
@@ -588,45 +1605,84 @@
588 1605 if ($cached !== false) {
589 1606 return $cached === 'yes';
590 1607 }
591 1608
592 - # get the site url
1609 + # No cached result yet — compute it off the request so we never block admin
1610 + # with a loopback call. Show nothing this load.
1611 + metasync_schedule_otto_js_check();
1612 + return false;
1613 +};
1614 +
1615 +# Queue the background loopback detection if it isn't already scheduled.
1616 +function metasync_schedule_otto_js_check(){
1617 + if (!wp_next_scheduled('metasync_otto_js_check_event')) {
1618 + wp_schedule_single_event(time() + 5, 'metasync_otto_js_check_event');
1619 + }
1620 +}
1621 +
1622 +# Background worker (runs in WP-Cron context, NOT on the admin request): performs the
1623 +# blocking loopback request and caches the result. Hooked to metasync_otto_js_check_event.
1624 +function metasync_run_otto_js_check(){
1625 + $cache_key = 'metasync_otto_js_detected';
1626 +
1627 + # the site url with the internal-fetch marker so OTTO does not re-process it
593 1628 $site_url = site_url() . '?is_otto_page_fetch=1';
594 1629
595 - # get the html — short timeout so admin doesn't hang
596 1630 $page_data = wp_remote_get($site_url, array('timeout' => 5, 'sslverify' => false));
597 1631
598 1632 if (is_wp_error($page_data)) {
1633 + # Loopback failed (host likely blocks same-server requests). Cache "no" so we
1634 + # don't keep re-queuing the check on every admin page load.
599 1635 set_transient($cache_key, 'no', HOUR_IN_SECONDS);
600 - return false;
1636 + return;
601 1637 }
602 1638
603 - # now get the html body
604 1639 $body = wp_remote_retrieve_body($page_data);
605 1640
606 - # now load the body into html
607 - $dom = new HtmlDocument($body);
1641 + # Detect the OTTO script via a lightweight regex instead of parsing
1642 + # the entire fetched page into a SimpleHtmlDom tree (which exhausted memory
1643 + # on large pages — this check runs on admin page loads). We only need to
1644 + # confirm a <script id="sa-dynamic-optimization" ... data-uuid="..."> exists.
1645 + $found = preg_match(
1646 + '/<script\b[^>]*\bid=["\']sa-dynamic-optimization["\'][^>]*\bdata-uuid=["\'][^"\']+["\']/i',
1647 + (string) $body
1648 + )
1649 + || preg_match(
1650 + # Attribute order may vary (data-uuid before id)
1651 + '/<script\b[^>]*\bdata-uuid=["\'][^"\']+["\'][^>]*\bid=["\']sa-dynamic-optimization["\']/i',
1652 + (string) $body
1653 + );
608 1654
609 - # now check the dom for a meta tag with
610 - $script = $dom->find('script#sa-dynamic-optimization', 0);
611 -
612 - # check script
613 - if($script AND !empty($script->getAttribute('data-uuid'))){
1655 + if ($found) {
614 1656 set_transient($cache_key, 'yes', 12 * HOUR_IN_SECONDS);
615 - return true;
1657 + return;
616 1658 }
617 1659
618 1660 set_transient($cache_key, 'no', 12 * HOUR_IN_SECONDS);
619 - return false;
620 -};
1661 +}
621 1662
1663 +# Register the background worker hook.
1664 +add_action('metasync_otto_js_check_event', 'metasync_run_otto_js_check');
1665 +
622 1666 # Handle AJAX Clear Cache request
623 1667 # NOTE: Cache system removed - this is now a no-op
624 1668 function metasync_clear_otto_cache_handler() {
1669 + # State-changing admin action: verify the nonce sent by admin/js
1670 + # (clear_otto_caches) and confirm plugin access before clearing the
1671 + # detection transient or queueing the loopback re-check.
1672 + check_ajax_referer('metasync_clear_otto_cache', 'nonce');
1673 +
1674 + if (!Metasync::current_user_has_plugin_access()) {
1675 + wp_send_json_error(['message' => 'Not authorized']);
1676 + }
1677 +
625 1678 if (!empty($_GET['clear_otto_cache'])) {
1679 + delete_transient('metasync_otto_js_detected');
1680 + # Re-run the JS detection in the background so the notice refreshes.
1681 + metasync_schedule_otto_js_check();
626 1682 # Cache system has been removed - no cache to clear
627 1683 wp_send_json_success(['message' => 'Cache system removed - all pages processed in real-time']);
628 - }
1684 + }
629 1685 else {
630 1686 wp_send_json_error(['message' => 'Missing parameter']);
631 1687 }
632 1688 }
@@ -656,33 +1712,97 @@
656 1712 }
657 1713
658 1714 add_action('admin_notices', 'metasync_show_otto_ssr_notice');
659 1715
1716 +
660 1717 # staging dummy change
661 -# load otto in the wp hook
1718 +# load otto in the wp hook
662 1719 add_action('wp', 'metasync_start_otto');
663 1720
664 -
665 1721 # ENHANCED OTTO SEO INTEGRATION
666 1722 # Register async SEO processing hook
667 - add_action('metasync_process_seo_job', 'metasync_process_otto_seo_data');
668 -
1723 + add_action('metasync_process_seo_job', 'metasync_process_otto_seo_data', 10, 4);
1724 + add_action('metasync_process_otto_crawl_url_job', 'metasync_handle_otto_crawl_url_job', 10, 2);
1725 + add_action('metasync_process_otto_batch_cache_job', 'metasync_handle_otto_batch_cache_job', 10, 2);
1726 + add_action('metasync_otto_pending_drainer', 'metasync_handle_otto_pending_drainer');
1727 +
669 1728 # Process OTTO SEO data and update WordPress meta fields for SEO plugins
670 1729 # This function now runs asynchronously via WordPress cron system
671 -
672 -function metasync_process_otto_seo_data($route) {
1730 + #
1731 + # Returns one of the Metasync_Otto_Job_Status::OUTCOME_* strings so callers
1732 + # can distinguish success, confirmed no-change, retryable failure, permanent
1733 + # failure, and CPU deferral — a bare boolean cannot drive correct retry and
1734 + # purge decisions.
1735 + #
1736 + # @param string $route Fully-qualified URL to process.
1737 + # @param bool $allow_defer When false, skip CPU-deferral rescheduling (used when
1738 + # called synchronously from crawl_url_job whose own retry
1739 + # mechanism already handles failures).
1740 + # @param int $deferral_count How many times this job has already been deferred for
1741 + # CPU load. Prevents infinite reschedule loops.
1742 + # @param array|null $prefetched_seo_data Suggestions already fetched by the transient
1743 + # warm step. When an array it is used as-is so the job
1744 + # makes exactly ONE API call per URL; the endpoint is
1745 + # only fetched here when no prefetch was provided.
1746 +
1747 +function metasync_process_otto_seo_data($route, $allow_defer = true, $deferral_count = 0, $prefetched_seo_data = null) {
1748 + # Maximum number of times a job can be deferred before it is dropped.
1749 + $max_deferrals = defined('METASYNC_SEO_JOB_MAX_DEFERRALS') ? METASYNC_SEO_JOB_MAX_DEFERRALS : 5;
1750 + $lock_key = null;
1751 + $lock_acquired = false;
1752 +
673 1753 try {
674 1754 # Validate input
675 1755 if (empty($route) || !is_string($route)) {
676 - return false;
1756 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
677 1757 }
678 1758
679 - # CPU load check — defer if server is under load
1759 + # CPU load check — defer if server is under load.
1760 + # Only reschedule when called from the cron hook (allow_defer=true) and
1761 + # we haven't exceeded the maximum deferral count.
1762 + # Checked BEFORE acquiring the lock so that deferred jobs don't
1763 + # acquire-then-immediately-release it (which defeats concurrency protection).
680 1764 if (!Metasync_CPU_Monitor::is_load_safe()) {
681 - wp_schedule_single_event(time() + 60, 'metasync_process_seo_job', array($route));
682 - return false;
1765 + if ($allow_defer) {
1766 + if ($deferral_count < $max_deferrals) {
1767 + metasync_otto_schedule_single_event(
1768 + time() + 60,
1769 + 'metasync_process_seo_job',
1770 + array($route, true, $deferral_count + 1)
1771 + );
1772 + return Metasync_Otto_Job_Status::OUTCOME_DEFERRED;
1773 + }
1774 + # deferral budget exhausted — RUN the job late instead
1775 + # of dropping it. The write is idempotent and bounded, and a
1776 + # late sync is strictly better than SEO data that never
1777 + # arrives. No new cron events are created, so the
1778 + # anti-pile-up guarantee is preserved. Fall through.
1779 + } else {
1780 + # allow_defer=false (sync path): report retryable without creating
1781 + # cron events — the crawl-url job's retry mechanism (or OTTO's
1782 + # next crawl) re-triggers this URL.
1783 + return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
1784 + }
683 1785 }
684 1786
1787 + # Concurrency lock: prevent multiple SEO jobs from running simultaneously.
1788 + # Uses a per-URL transient lock with a 120s TTL as a safety net (the lock is
1789 + # explicitly deleted on completion). If the lock exists another run is already
1790 + # processing this URL — reschedule once with a short delay instead of stacking.
1791 + $lock_key = 'metasync_seo_lock_' . md5($route);
1792 + if (get_transient($lock_key) !== false) {
1793 + if ($allow_defer && $deferral_count < $max_deferrals) {
1794 + metasync_otto_schedule_single_event(
1795 + time() + 30,
1796 + 'metasync_process_seo_job',
1797 + array($route, true, $deferral_count + 1)
1798 + );
1799 + }
1800 + return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
1801 + }
1802 + set_transient($lock_key, true, 120);
1803 + $lock_acquired = true;
1804 +
685 1805 # Resolve redirect table: use final destination URL before 404 checks and OTTO processing
686 1806 $route = metasync_otto_resolve_redirect_to_final_url($route);
687 1807
688 1808 # Skip excluded URLs - don't process SEO data for them
@@ -687,16 +1807,15 @@
687 1807
688 1808 # Skip excluded URLs - don't process SEO data for them
689 1809 if (metasync_is_otto_url_excluded($route)) {
690 1810 //error_log('MetaSync OTTO: Skipping SEO processing for excluded URL: ' . $route);
691 - return false;
1811 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
692 1812 }
693 1813
694 1814 # Pre-flight 404 check: exclude URLs that would return 404 before making API call
695 1815 if (!metasync_otto_is_url_available($route)) {
696 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route}");
697 1816 metasync_otto_auto_exclude_404_url($route);
698 - return false;
1817 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
699 1818 }
700 1819
701 1820 # Get OTTO UUID from settings
702 1821 # OPTIMIZED: Use cached options
@@ -702,24 +1821,40 @@
702 1821 # OPTIMIZED: Use cached options
703 1822 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
704 1823
705 1824 if (empty($otto_uuid)) {
706 - return false;
1825 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
707 1826 }
708 1827
709 1828 # Meta descriptions are always enabled by default - no check needed
710 1829
711 - # Fetch SEO data from OTTO API
712 - $seo_data = metasync_fetch_otto_seo_data($route, $otto_uuid);
713 -
714 - if (!$seo_data) {
715 - metasync_record_failed_action( 'metasync_process_seo_job' );
716 - return false;
1830 + # Obtain the OTTO suggestions for this URL. When the caller already
1831 + # warmed the transient cache (crawl-url job), those suggestions are
1832 + # handed in and NO second API call is made — the endpoint, rate
1833 + # limiter, and breaker are only hit once per URL.
1834 + # An empty array is a legitimate answer ("OTTO holds nothing for this
1835 + # URL") and must not be treated as a fetch failure; only a strict
1836 + # false from the fetcher is a failure.
1837 + if (is_array($prefetched_seo_data)) {
1838 + $seo_data = $prefetched_seo_data;
1839 + } else {
1840 + $failure_class = null;
1841 + $seo_data = metasync_fetch_otto_seo_data($route, $otto_uuid, $failure_class);
1842 + if ($seo_data === false) {
1843 + if ($failure_class === Metasync_Otto_Job_Status::OUTCOME_PERMANENT) {
1844 + # Auth/input-level rejection from the API — retrying the
1845 + # same request cannot help.
1846 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1847 + }
1848 + # Timeout / 5xx / 429 / empty body / bad JSON: retryable.
1849 + metasync_record_failed_action( 'metasync_process_seo_job' );
1850 + return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
1851 + }
717 1852 }
718 1853
719 1854 # Mark this URL as crawled by OTTO for SSR
720 1855 # Extract domain and path from route
721 - $parsed_url = parse_url($route);
1856 + $parsed_url = wp_parse_url($route);
722 1857 $domain_with_scheme = ($parsed_url['scheme'] ?? 'https') . '://' . ($parsed_url['host'] ?? '');
723 1858 $url_path = ($parsed_url['path'] ?? '/');
724 1859
725 1860 # Create crawl data structure
@@ -752,9 +1887,9 @@
752 1887
753 1888 # Try to find WooCommerce product by URL if url_to_postid failed
754 1889 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
755 1890 # Extract product slug from URL
756 - $product_slug = basename(parse_url($route, PHP_URL_PATH));
1891 + $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
757 1892
758 1893 # Try to get product by slug
759 1894 $products = wc_get_products(array(
760 1895 'name' => $product_slug,
@@ -785,17 +1920,16 @@
785 1920 if (!$post_id || $post_id <= 0) {
786 1921 # Check if this is a category page
787 1922 if (strpos($route, '/category/') !== false) {
788 1923 # Extract category slug from URL
789 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
1924 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
790 1925 $category = get_category_by_slug($category_slug);
791 1926
792 1927 if ($category) {
793 1928 # Check if category would return 404 before applying OTTO changes
794 1929 if (metasync_would_term_return_404($category->term_id, 'category', $route)) {
795 - error_log("MetaSync OTTO: Skipping SEO processing for category that would return 404: {$route} (Category ID: {$category->term_id})");
796 1930 metasync_otto_auto_exclude_404_url($route);
797 - return false;
1931 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
798 1932 }
799 1933
800 1934 # Update comprehensive category SEO meta fields
801 1935 $update_result = metasync_update_comprehensive_category_seo_fields($category->term_id, $seo_data);
@@ -858,8 +1992,12 @@
858 1992 $title = 'Category Structured Data Update';
859 1993 break;
860 1994 }
861 1995
1996 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
1997 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
1998 + }
1999 +
862 2000 if (!empty($title)) {
863 2001 metasync_log_sync_history([
864 2002 'title' => $title,
865 2003 'source' => 'OTTO SEO',
@@ -875,12 +2013,12 @@
875 2013 ]);
876 2014 }
877 2015 }
878 2016
879 - return true;
2017 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
880 2018 }
881 2019
882 - return false;
2020 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
883 2021 }
884 2022 }
885 2023
886 2024 # Check if this is a WooCommerce product category
@@ -885,17 +2023,16 @@
885 2023
886 2024 # Check if this is a WooCommerce product category
887 2025 if (strpos($route, '/product-category/') !== false) {
888 2026 # Extract product category slug from URL
889 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2027 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
890 2028 $term = get_term_by('slug', $category_slug, 'product_cat');
891 2029
892 2030 if ($term && !is_wp_error($term)) {
893 2031 # Check if product category would return 404 before applying OTTO changes
894 2032 if (metasync_would_term_return_404($term->term_id, 'product_cat', $route)) {
895 - error_log("MetaSync OTTO: Skipping SEO processing for product category that would return 404: {$route} (Term ID: {$term->term_id})");
896 2033 metasync_otto_auto_exclude_404_url($route);
897 - return false;
2034 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
898 2035 }
899 2036
900 2037 # Update comprehensive taxonomy SEO meta fields
901 2038 $update_result = metasync_update_comprehensive_taxonomy_seo_fields($term->term_id, 'product_cat', $seo_data);
@@ -958,8 +2095,12 @@
958 2095 $title = 'Product Category Structured Data Update';
959 2096 break;
960 2097 }
961 2098
2099 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2100 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2101 + }
2102 +
962 2103 if (!empty($title)) {
963 2104 metasync_log_sync_history([
964 2105 'title' => $title,
965 2106 'source' => 'OTTO SEO',
@@ -976,12 +2117,12 @@
976 2117 ]);
977 2118 }
978 2119 }
979 2120
980 - return true;
2121 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
981 2122 }
982 2123
983 - return false;
2124 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
984 2125 }
985 2126 }
986 2127
987 2128 # Check if this is the home page (landing page)
@@ -1002,11 +2143,10 @@
1002 2143
1003 2144 if ($home_page) {
1004 2145 # Check if home page would return 404 before applying OTTO changes
1005 2146 if (metasync_would_page_return_404($home_page->ID, $route)) {
1006 - error_log("MetaSync OTTO: Skipping SEO processing for home page that would return 404: {$route} (Post ID: {$home_page->ID}, Status: {$home_page->post_status})");
1007 2147 metasync_otto_auto_exclude_404_url($route);
1008 - return false;
2148 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1009 2149 }
1010 2150
1011 2151 # Update comprehensive home page SEO meta fields
1012 2152 $update_result = metasync_update_comprehensive_seo_fields($home_page->ID, $seo_data);
@@ -1072,8 +2212,12 @@
1072 2212 $title = 'Home Page Structured Data Update';
1073 2213 break;
1074 2214 }
1075 2215
2216 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2217 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2218 + }
2219 +
1076 2220 if (!empty($title)) {
1077 2221 metasync_log_sync_history([
1078 2222 'title' => $title,
1079 2223 'source' => 'OTTO SEO',
@@ -1088,12 +2232,12 @@
1088 2232 ]);
1089 2233 }
1090 2234 }
1091 2235
1092 - return true;
2236 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
1093 2237 }
1094 2238
1095 - return false;
2239 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
1096 2240 }
1097 2241 }
1098 2242
1099 2243 # Check if this is the blog/posts page (page_for_posts)
@@ -1104,11 +2248,10 @@
1104 2248
1105 2249 if ($posts_page && $route_clean === $posts_page_url) {
1106 2250 # Check if blog page would return 404
1107 2251 if (metasync_would_page_return_404($posts_page->ID, $route)) {
1108 - error_log("MetaSync OTTO: Skipping SEO processing for blog page that would return 404: {$route} (Post ID: {$posts_page->ID}, Status: {$posts_page->post_status})");
1109 2252 metasync_otto_auto_exclude_404_url($route);
1110 - return false;
2253 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1111 2254 }
1112 2255
1113 2256 # Update comprehensive blog page SEO meta fields
1114 2257 $update_result = metasync_update_comprehensive_seo_fields($posts_page->ID, $seo_data);
@@ -1174,8 +2317,12 @@
1174 2317 $title = 'Blog Page Structured Data Update';
1175 2318 break;
1176 2319 }
1177 2320
2321 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2322 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2323 + }
2324 +
1178 2325 if (!empty($title)) {
1179 2326 metasync_log_sync_history([
1180 2327 'title' => $title,
1181 2328 'source' => 'OTTO SEO',
@@ -1190,12 +2337,12 @@
1190 2337 ]);
1191 2338 }
1192 2339 }
1193 2340
1194 - return true;
2341 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
1195 2342 }
1196 2343
1197 - return false;
2344 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
1198 2345 }
1199 2346 }
1200 2347
1201 2348 # URL didn't resolve to any supported entity (post, category, home page, blog page)
@@ -1200,12 +2347,11 @@
1200 2347
1201 2348 # URL didn't resolve to any supported entity (post, category, home page, blog page)
1202 2349 # Treat as 404 and auto-exclude (e.g. deleted post, non-existent page)
1203 2350 if (!metasync_otto_is_url_available($route)) {
1204 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404 (no matching entity): {$route}");
1205 2351 metasync_otto_auto_exclude_404_url($route);
1206 2352 }
1207 - return false;
2353 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1208 2354 }
1209 2355
1210 2356 # Verify this is actually a post, page, or WooCommerce product
1211 2357 $post = get_post($post_id);
@@ -1214,16 +2360,15 @@
1214 2360 $supported_post_types = metasync_get_supported_post_types();
1215 2361
1216 2362 if (!$post || !in_array($post->post_type, $supported_post_types)) {
1217 2363 # Skip unsupported post types
1218 - return false;
2364 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1219 2365 }
1220 2366
1221 2367 # Check if page would return 404 before applying OTTO changes
1222 2368 if (metasync_would_page_return_404($post_id, $route)) {
1223 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route} (Post ID: {$post_id}, Status: {$post->post_status})");
1224 2369 metasync_otto_auto_exclude_404_url($route);
1225 - return false;
2370 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1226 2371 }
1227 2372
1228 2373 # Update comprehensive SEO meta fields
1229 2374 $update_result = metasync_update_comprehensive_seo_fields($post_id, $seo_data);
@@ -1289,8 +2434,12 @@
1289 2434 $title = 'Structured Data Update';
1290 2435 break;
1291 2436 }
1292 2437
2438 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2439 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2440 + }
2441 +
1293 2442 if (!empty($title)) {
1294 2443 metasync_log_sync_history([
1295 2444 'title' => $title,
1296 2445 'source' => 'OTTO SEO',
@@ -1305,16 +2454,23 @@
1305 2454 ]);
1306 2455 }
1307 2456 }
1308 2457
1309 - return true;
2458 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
1310 2459 }
1311 2460
1312 - return false;
2461 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
1313 2462
1314 2463 } catch (Exception $e) {
1315 2464 metasync_record_failed_action( 'metasync_process_seo_job' );
1316 - return false;
2465 + return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
2466 + } finally {
2467 + # Release the concurrency lock only if we actually acquired it.
2468 + # Early returns (CPU deferral, lock contention) must NOT delete a lock
2469 + # that another process may be holding.
2470 + if ($lock_acquired && $lock_key) {
2471 + delete_transient($lock_key);
2472 + }
1317 2473 }
1318 2474 }
1319 2475
1320 2476 /**
@@ -1328,11 +2484,10 @@
1328 2484
1329 2485 // Minimal duplicate prevention within short time window
1330 2486 if (!empty($data['title']) && !empty($data['source'])) {
1331 2487 global $wpdb;
1332 - $table = $wpdb->prefix . Metasync_Sync_History_Database::$table_name;
1333 - $recent = $wpdb->get_var($wpdb->prepare(
1334 - "SELECT COUNT(*) FROM `$table` WHERE title = %s AND source = %s AND created_at >= %s",
2488 + $recent = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- sync-history duplicate guard over the plugin custom table — write-path freshness required
2489 + "SELECT COUNT(*) FROM `{$wpdb->prefix}metasync_sync_history` WHERE title = %s AND source = %s AND created_at >= %s",
1335 2490 $data['title'],
1336 2491 $data['source'],
1337 2492 gmdate('Y-m-d H:i:s', time() - 60)
1338 2493 ));
@@ -1343,9 +2498,9 @@
1343 2498
1344 2499 $sync_db->add($data);
1345 2500
1346 2501 } catch (Exception $e) {
1347 - error_log("MetaSync: Failed to log sync history: " . $e->getMessage());
2502 + error_log("MetaSync: Failed to log sync history: " . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1348 2503 }
1349 2504 }
1350 2505
1351 2506 /**
@@ -1421,9 +2576,9 @@
1421 2576 $db = new Metasync_Redirection_Database();
1422 2577 $redirect = new Metasync_Redirection($db);
1423 2578 return $redirect->resolve_url_to_final_destination($url, 10);
1424 2579 } catch (Exception $e) {
1425 - error_log('MetaSync OTTO: Redirect resolution failed for ' . $url . ' - ' . $e->getMessage());
2580 + error_log('MetaSync OTTO: Redirect resolution failed for ' . $url . ' - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1426 2581 return $url;
1427 2582 }
1428 2583 }
1429 2584
@@ -1438,8 +2593,13 @@
1438 2593 {
1439 2594 if (empty($url) || !is_string($url)) {
1440 2595 return false;
1441 2596 }
2597 + $url = filter_var($url, FILTER_SANITIZE_URL);
2598 + $url = esc_url_raw($url);
2599 + if (empty($url) || mb_strlen($url) > 2048) {
2600 + return false;
2601 + }
1442 2602 try {
1443 2603 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1444 2604 $db = new Metasync_Otto_Excluded_URLs_Database();
1445 2605 return $db->add([
@@ -1449,9 +2609,9 @@
1449 2609 'status' => 'active',
1450 2610 'auto_excluded' => 1,
1451 2611 ]);
1452 2612 } catch (Exception $e) {
1453 - error_log('MetaSync OTTO: Failed to auto-exclude 404 URL: ' . $url . ' - ' . $e->getMessage());
2613 + error_log('MetaSync OTTO: Failed to auto-exclude 404 URL: ' . $url . ' - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1454 2614 return false;
1455 2615 }
1456 2616 }
1457 2617
@@ -1471,14 +2631,13 @@
1471 2631 try {
1472 2632 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1473 2633 $db = new Metasync_Otto_Excluded_URLs_Database();
1474 2634 global $wpdb;
1475 - $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
1476 2635 # Normalize the same way is_url_excluded() does
1477 2636 $url_normalized = rtrim(trim($url), '/');
1478 - $records = $wpdb->get_results(
2637 + $records = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- webhook-path cleanup of auto-exclusions over the plugin custom table
1479 2638 $wpdb->prepare(
1480 - "SELECT id FROM `{$table}` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'",
2639 + "SELECT id FROM `{$wpdb->prefix}metasync_otto_excluded_urls` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'",
1481 2640 $url_normalized
1482 2641 )
1483 2642 );
1484 2643 if (!empty($records)) {
@@ -1508,22 +2667,30 @@
1508 2667 }
1509 2668 try {
1510 2669 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1511 2670 global $wpdb;
1512 - $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
1513 2671 $url_normalized = rtrim(trim($url), '/');
1514 2672
1515 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- no user input, table name from $wpdb->prefix
1516 - $records = $wpdb->get_results(
1517 - "SELECT url_pattern, pattern_type FROM `{$table}` WHERE status = 'active' AND (auto_excluded = 0 OR auto_excluded IS NULL) ORDER BY created_at DESC"
1518 - );
2673 + $records = get_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY);
1519 2674
1520 - // Graceful recovery: auto_excluded column missing on pre-v2.7.4 installs.
1521 - // Run ALTER TABLE to add it and treat URL as not excluded so OTTO continues rendering.
1522 - if ($wpdb->last_error && strpos($wpdb->last_error, 'auto_excluded') !== false) {
1523 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1524 - $wpdb->query("ALTER TABLE `{$table}` ADD COLUMN `auto_excluded` TINYINT(1) NOT NULL DEFAULT 0");
1525 - return false;
2675 + if ($records === false) {
2676 + $records = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- render-path read IS cached via get/set_transient with invalidation on every write — wp_cache_* is not used because the payload must survive object-cache-only setups
2677 + "SELECT url_pattern, pattern_type FROM `{$wpdb->prefix}metasync_otto_excluded_urls` WHERE status = 'active' AND (auto_excluded = 0 OR auto_excluded IS NULL) ORDER BY created_at DESC"
2678 + );
2679 +
2680 + // Graceful recovery: auto_excluded column missing on pre-v2.7.4 installs.
2681 + // Schema changes belong to the upgrade routines, never the render path:
2682 + // degrade to "not excluded" so OTTO keeps rendering, and schedule a
2683 + // one-shot background repair that runs the same column migration off
2684 + // the visitor request (handled by metasync_repair_otto_excluded_schema).
2685 + if ($wpdb->last_error && strpos($wpdb->last_error, 'auto_excluded') !== false) {
2686 + if (false === wp_next_scheduled('metasync_repair_otto_excluded_schema')) {
2687 + wp_schedule_single_event(time() + MINUTE_IN_SECONDS, 'metasync_repair_otto_excluded_schema');
2688 + }
2689 + return false;
2690 + }
2691 +
2692 + set_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY, $records ?: [], METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL);
1526 2693 }
1527 2694
1528 2695 if (empty($records)) {
1529 2696 return false;
@@ -1529,27 +2696,16 @@
1529 2696 return false;
1530 2697 }
1531 2698
1532 2699 foreach ($records as $excluded) {
1533 - $pattern = rtrim(trim($excluded->url_pattern), '/');
1534 - $pattern_type = $excluded->pattern_type;
1535 -
1536 - switch ($pattern_type) {
1537 - case 'exact':
1538 - if ($url_normalized === $pattern) {
1539 - return true;
1540 - }
1541 - break;
1542 - case 'contain':
1543 - if (strpos($url_normalized, $pattern) !== false) {
1544 - return true;
1545 - }
1546 - break;
1547 - case 'start':
1548 - if (strpos($url_normalized, $pattern) === 0) {
1549 - return true;
1550 - }
1551 - break;
2700 + # Delegate to the shared matcher so this gate supports every pattern type the
2701 + # add handler accepts (exact|contain|start|end|regex). Keeping a second switch
2702 + # here is what let `end` and `regex` exclusions be silently ignored on the
2703 + # visitor render path while still applying on the queue/SEO-write path.
2704 + # The raw pattern is passed through - the matcher normalizes it per type,
2705 + # because a regex must keep the trailing slash that closes its delimiters.
2706 + if (Metasync_Otto_Excluded_URLs_Database::pattern_matches($url_normalized, $excluded->url_pattern, $excluded->pattern_type)) {
2707 + return true;
1552 2708 }
1553 2709 }
1554 2710 return false;
1555 2711 } catch (Exception $e) {
@@ -1606,9 +2762,9 @@
1606 2762 }
1607 2763
1608 2764 # WooCommerce product by slug
1609 2765 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
1610 - $product_slug = basename(parse_url($route, PHP_URL_PATH));
2766 + $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1611 2767 $products = wc_get_products(array('name' => $product_slug, 'limit' => 1, 'status' => 'publish'));
1612 2768 if (!empty($products)) {
1613 2769 $post_id = $products[0]->get_id();
1614 2770 } else {
@@ -1635,9 +2791,9 @@
1635 2791 }
1636 2792
1637 2793 # Category
1638 2794 if (strpos($route, '/category/') !== false) {
1639 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2795 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1640 2796 $category = get_category_by_slug($category_slug);
1641 2797 if ($category) {
1642 2798 return !metasync_would_term_return_404($category->term_id, 'category', $route);
1643 2799 }
@@ -1644,9 +2800,9 @@
1644 2800 }
1645 2801
1646 2802 # WooCommerce product category
1647 2803 if (strpos($route, '/product-category/') !== false) {
1648 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2804 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1649 2805 $term = get_term_by('slug', $category_slug, 'product_cat');
1650 2806 if ($term && !is_wp_error($term)) {
1651 2807 return !metasync_would_term_return_404($term->term_id, 'product_cat', $route);
1652 2808 }
@@ -1688,9 +2844,9 @@
1688 2844
1689 2845 $removed = 0;
1690 2846 $marked_permanent = 0;
1691 2847 $thirty_days_ago = strtotime('-30 days');
1692 - $next_recheck = date('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS);
2848 + $next_recheck = gmdate('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS);
1693 2849
1694 2850 foreach ($records as $record) {
1695 2851 $url = trim($record->url_pattern);
1696 2852 if (empty($url)) {
@@ -1711,16 +2867,10 @@
1711 2867 }
1712 2868 }
1713 2869 }
1714 2870
1715 - if ($removed > 0) {
1716 - error_log("MetaSync OTTO: Recheck 404 exclusions - removed {$removed} URL(s) that are now available");
1717 - }
1718 - if ($marked_permanent > 0) {
1719 - error_log("MetaSync OTTO: Recheck 404 exclusions - marked {$marked_permanent} URL(s) as permanent (still 404 after 30 days)");
1720 - }
1721 2871 } catch (Exception $e) {
1722 - error_log('MetaSync OTTO: Recheck 404 exclusions failed - ' . $e->getMessage());
2872 + error_log('MetaSync OTTO: Recheck 404 exclusions failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1723 2873 }
1724 2874 }
1725 2875
1726 2876 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
@@ -1725,8 +2875,24 @@
1725 2875
1726 2876 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
1727 2877
1728 2878 /**
2879 + * Background repair of the metasync_otto_excluded_urls schema (cron).
2880 + *
2881 + * Scheduled by metasync_is_otto_url_manually_excluded() when a render-path
2882 + * read fails because the auto_excluded column is missing (pre-2.7.4 install
2883 + * whose version migration did not run). Runs the DB layer's self-healing
2884 + * column migration off the visitor request instead of ALTERing inline.
2885 + */
2886 +function metasync_repair_otto_excluded_schema()
2887 +{
2888 + require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2889 + Metasync_Otto_Excluded_URLs_Database::ensure_schema();
2890 +}
2891 +
2892 +add_action('metasync_repair_otto_excluded_schema', 'metasync_repair_otto_excluded_schema');
2893 +
2894 +/**
1729 2895 * Check if a post/page would return 404 without making HTTP request
1730 2896 * Uses WordPress database checks for fast validation
1731 2897 *
1732 2898 * @param int $post_id WordPress post ID
@@ -1834,5 +3000,88 @@
1834 3000 # Check if this post has Brizy metadata
1835 3001 if (get_post_meta($post_id, 'brizy_post_uid', true)) {
1836 3002 delete_transient('metasync_has_brizy_posts');
1837 3003 }
1838 -}, 10, 1);
3004 +}, 10, 1);
3005 +
3006 +/**
3007 + * Identify infrastructure agents that must never hit the render throttle.
3008 + *
3009 + * Two classes of agent are covered:
3010 + *
3011 + * - Page-cache warmers. A host preloader (WP Cloud PageCacheBot, WP Rocket preload,
3012 + * LiteSpeed, SG Optimizer, ...) exists purely to populate the page cache. Throttling
3013 + * one returns un-OTTO'd HTML *and* defines DONOTCACHEPAGE, so the cache can never be
3014 + * filled and every subsequent human visitor pays a full uncached render. Measured on
3015 + * the reporting site: desktop cache MISS on 17/17 requests at ~2.8s TTFB, versus
3016 + * ~0.56s with OTTO disabled.
3017 + *
3018 + * - Synthetic performance auditors (Lighthouse / PageSpeed Insights / GTmetrix).
3019 + * These are what customers measure with, so they must receive the same fully
3020 + * rendered OTTO output a real visitor gets. Currently `/lighthouse/i` and
3021 + * `/pagespeed/i` are generic-bot patterns, so a second PSI run inside the 5-minute
3022 + * window is served the throttled, un-OTTO'd, uncacheable page.
3023 + *
3024 + * Matching is done on the raw user-agent, deliberately independent of the bot-name
3025 + * categorisation in Metasync_Otto_Bot_Detector.
3026 + *
3027 + * @param mixed $detection Result of Metasync_Otto_Bot_Detector::detect(), or malformed input.
3028 + * @return bool True when this request must bypass the render throttle.
3029 + */
3030 +function metasync_otto_is_unthrottled_infrastructure_agent( $detection = array() ) {
3031 +
3032 + $ua = '';
3033 + if ( is_array( $detection ) && ! empty( $detection['user_agent'] ) ) {
3034 + $ua = (string) $detection['user_agent'];
3035 + } elseif ( isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
3036 + $ua = (string) $_SERVER['HTTP_USER_AGENT'];
3037 + }
3038 +
3039 + $needles = array(
3040 + // Page-cache preloaders.
3041 + 'pagecachebot', // WP Cloud / Automattic (PageCacheBotDesktop|Mobile)
3042 + 'wp rocket', // WP Rocket preload
3043 + 'wprocket',
3044 + 'lscache_runner', // LiteSpeed Cache crawler
3045 + 'sg-optimizer', // SiteGround
3046 + 'sgoptimizer',
3047 + 'nitropack',
3048 + 'cache-warmer',
3049 + 'cachewarmer',
3050 + 'cache warmer',
3051 + // Synthetic performance auditors.
3052 + 'lighthouse',
3053 + 'pagespeed',
3054 + 'gtmetrix',
3055 + 'pingdom',
3056 + 'webpagetest',
3057 + );
3058 +
3059 + $is_infra = false;
3060 +
3061 + if ( '' !== $ua ) {
3062 + $ua_lower = strtolower( $ua );
3063 + foreach ( $needles as $needle ) {
3064 + if ( false !== strpos( $ua_lower, $needle ) ) {
3065 + $is_infra = true;
3066 + break;
3067 + }
3068 + }
3069 + }
3070 +
3071 + // WP Cloud's warmer also tags its requests with a query marker.
3072 + if ( ! $is_infra && isset( $_GET['x-cache-engine'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only flag for render throttling
3073 + $is_infra = true;
3074 + }
3075 +
3076 + /**
3077 + * Allow a site or host to declare additional preload agents.
3078 + *
3079 + * @param bool $is_infra Whether this request bypasses the OTTO render throttle.
3080 + * @param string $ua The request user-agent.
3081 + */
3082 + if ( function_exists( 'apply_filters' ) ) {
3083 + $is_infra = (bool) apply_filters( 'metasync_otto_unthrottled_agent', $is_infra, $ua );
3084 + }
3085 +
3086 + return $is_infra;
3087 +}