PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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 +681 -256 2.6.23 → 2.7.2 View file →
@@ -16,18 +16,67 @@
16 16 # uses the simple html dom library
17 17 use simplehtmldom\HtmlDocument;
18 18
19 19 # include the otto class file
20 -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 +}
21 43 require_once plugin_dir_path( __FILE__ ) . '/Otto_html_class.php';
22 44 require_once plugin_dir_path( __FILE__ ) . '/Otto_pixel_class.php';
23 45 require_once plugin_dir_path( __FILE__ ) . '/metasync-otto-seo-functions.php';
24 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';
48 +require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-webhook-audit.php';
25 49 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-render-strategy.php';
26 50 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-config.php';
27 51 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-detector.php';
28 52 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-statistics-database.php';
29 53
54 +/**
55 + * Is $method actually available on $class in this process right now?
56 + *
57 + * A partially updated install can leave a newer copy of one plugin file beside
58 + * an older copy of another — stale opcache bytecode for a single file is enough.
59 + * class_exists() is satisfied by the older copy, and calling a method it does
60 + * not declare is a fatal. Everything in this file runs on the front end, so that
61 + * fatal is a white screen on every page view rather than a degraded feature.
62 + *
63 + * Defined here rather than pulled from a shared utility class on purpose: a
64 + * helper loaded from another file could itself be the stale one.
65 + *
66 + * $class and $method are parameters rather than literals at the call site so the
67 + * check survives static analysis, which would otherwise narrow a literal
68 + * method_exists() on a known class to a constant true — the skew this guards
69 + * against exists only at runtime.
70 + *
71 + * @param string $class Class about to be called.
72 + * @param string $method Method about to be called on it.
73 + * @return bool
74 + */
75 +function metasync_otto_class_provides($class, $method) {
76 + return class_exists($class) && method_exists($class, $method);
77 +}
78 +
30 79 # OPTIMIZED: get the metasync options (cached in static class)
31 80 $metasync_options = Metasync_Otto_Config::get_options();
32 81
33 82 # OTTO SSR is always enabled by default
@@ -51,9 +100,9 @@
51 100 # Use endpoint manager to get the correct domain
52 101 $otto_domain = 'sa.searchatlas.com'; # default
53 102 if (class_exists('Metasync_Endpoint_Manager')) {
54 103 $otto_api_domain = Metasync_Endpoint_Manager::get_endpoint('OTTO_API_DOMAIN');
55 - $parsed = parse_url($otto_api_domain);
104 + $parsed = wp_parse_url($otto_api_domain);
56 105 if (!empty($parsed['host'])) {
57 106 $otto_domain = $parsed['host'];
58 107 }
59 108 }
@@ -65,12 +114,54 @@
65 114 # OPTIMIZED: use cached uuid
66 115 $otto_tag = '<meta name="otto" content="uuid='.esc_attr(Metasync_Otto_Config::get_otto_uuid()).'; type=wordpress; enabled='.esc_attr($string_enabled).'; version='.esc_attr($plugin_version).'">';
67 116
68 117 # out the otto tag
118 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- meta tag assembled on the line above with esc_attr() on every interpolated value
69 119 echo $otto_tag;
70 120 }, 1); # Priority 1 to output early in head
71 121
72 122 /**
123 + * Scheduling seam for the OTTO pipeline.
124 + *
125 + * wp_schedule_single_event() returns false on failure and the old pipeline
126 + * ignored it, so a broken cron table acknowledged URLs that were never
127 + * queued. Every schedule in this pipeline goes through this seam so the
128 + * return value is honored (and tests can observe scheduling).
129 + */
130 +function metasync_otto_schedule_single_event($timestamp, $hook, $args = array()) {
131 + return wp_schedule_single_event($timestamp, $hook, $args) === true;
132 +}
133 +
134 +/**
135 + * Dedupe seam: does this URL already have live per-URL work in flight?
136 + *
137 + * Combines the durable status store with the raw cron table so a re-delivered
138 + * webhook cannot stack a second job behind a live one.
139 + */
140 +function metasync_otto_job_already_scheduled($route) {
141 + if (class_exists('Metasync_Otto_Job_Status', false)
142 + && Metasync_Otto_Job_Status::has_active($route)) {
143 + return true;
144 + }
145 + return wp_next_scheduled('metasync_process_otto_crawl_url_job', array($route)) !== false;
146 +}
147 +
148 +/**
149 + * Cron-table dedupe seam: does a per-URL cron event already exist for this URL?
150 + *
151 + * The pending-queue drainer must dedupe against the scheduler table only.
152 + * Every URL it takes off the durable queue carries a fresh webhook
153 + * accepted/batch_overflow ledger row; that row exists so a re-delivered
154 + * webhook cannot stack a second job behind live work, and consulting it
155 + * from the drainer — after take_pending() has already removed the URL
156 + * from the queue — silently dropped every overflow URL instead of
157 + * draining it. Only a real, still-pending cron event may veto here.
158 + */
159 +function metasync_otto_job_event_pending($route) {
160 + return wp_next_scheduled('metasync_process_otto_crawl_url_job', array($route)) !== false;
161 +}
162 +
163 +/**
73 164 * Start end point to handle requests on page updates
74 165 * The Otto Crawler will call this end point once a page is updated
75 166 **/
76 167
@@ -76,8 +167,13 @@
76 167
77 168 # function to register the route
78 169 function metasync_otto_crawl_notify($request){
79 170
171 + # receipt time is captured before validation and scheduling, so the audit
172 + # record describes when WordPress received the delivery, not when its work
173 + # happened to finish.
174 + $received_at = time();
175 +
80 176 # get request data params
81 177 $data = $request->get_json_params();
82 178
83 179 # fields
@@ -97,36 +193,48 @@
97 193 }
98 194
99 195 }
100 196
101 - # load otto pixel
102 - $otto_pixel = new Metasync_otto_pixel(false);
197 + # The audit trail is deliberately after payload validation: invalid requests
198 + # receive the existing 400 response and are not recorded as deliveries.
199 + # It is also before any scheduling so a valid delivery is captured even if
200 + # later work is deferred or rejected. The failure guard is intentionally
201 + # broader than Exception: a broken option adapter or stale plugin copy must
202 + # never turn an otherwise acknowledged webhook into a 500 response.
203 + try {
204 + if (metasync_otto_class_provides('Metasync_Otto_Webhook_Audit', 'record')) {
205 + Metasync_Otto_Webhook_Audit::record(
206 + $received_at,
207 + $data['domain'],
208 + $data['urls']
209 + );
210 + }
211 + } catch (Throwable $metasync_otto_audit_failure) {
212 + error_log(
213 + 'MetaSync OTTO: failed to record crawl-notify webhook audit entry: '
214 + . $metasync_otto_audit_failure->getMessage()
215 + ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- audit failure must not break webhook acknowledgement
216 + }
103 217
104 - # save the otto data first (cheap local DB write — kept synchronous)
105 - $otto_pixel->save_crawl_data($data);
106 -
107 218 # Defer the expensive per-URL work (OTTO API fetch, post meta sync,
108 219 # per-URL cache purge) to background jobs. The webhook caller enforces a
109 - # strict response-time budget; doing this work inline previously blocked
110 - # the response for up to 30s × N URLs and timed the caller out.
111 - # Each URL gets its own scheduled event so failures are isolated.
220 + # strict response-time budget, so the response acknowledges SCHEDULING,
221 + # never execution. The per-URL status store records what actually
222 + # happened to each URL once the jobs run.
112 223 #
113 - # Cap the number of per-URL cron jobs scheduled per webhook batch.
114 - # Large crawl batches (100+ URLs) previously created hundreds of cron events
115 - # that overwhelmed WP-Cron on shared/managed hosts like WP Engine. Excess
116 - # URLs beyond the cap are silently skipped — OTTO will re-crawl them on the
117 - # next webhook cycle.
224 + # Cap the number of per-URL cron jobs scheduled per webhook batch so a
225 + # large crawl cannot overwhelm WP-Cron on shared hosts. URLs past the cap
226 + # are queued durably and worked off by a self-rescheduling drainer job —
227 + # never silently dropped.
118 228 $max_jobs_per_batch = defined('METASYNC_MAX_JOBS_PER_BATCH') ? METASYNC_MAX_JOBS_PER_BATCH : 25;
119 229 $now = time();
120 230 $routes_to_process = array();
121 - $scheduled_count = 0;
231 + $accepted = 0;
232 + $deferred = 0;
233 + $rejected = 0;
234 + $scheduled_this_batch = 0;
122 235
123 236 foreach($data['urls'] AS $key => $url){
124 - # Enforce per-batch cap to prevent cron overload
125 - if ($scheduled_count >= $max_jobs_per_batch) {
126 - break;
127 - }
128 -
129 237 # prepare the route
130 238 $route = $data['domain'] . $url;
131 239
132 240 # validate the route
@@ -145,39 +253,71 @@
145 253 if (metasync_is_otto_url_excluded($route)) {
146 254 continue;
147 255 }
148 256
257 + # Every valid URL belongs to the batched cache-purge set even when its
258 + # per-URL meta-sync job is deduped or deferred: the crawler hit all of
259 + # them, so the caches for all of them must be refreshed.
260 + $routes_to_process[] = $route;
261 +
262 + # Duplicate delivery: an active job already owns this URL. Idempotent
263 + # no-op — not re-scheduled, not queued, not counted as new work.
264 + if (metasync_otto_job_already_scheduled($route)) {
265 + continue;
266 + }
267 +
268 + if ($scheduled_this_batch >= $max_jobs_per_batch) {
269 + # Batch overflow: queue durably for the drainer instead of dropping.
270 + if (Metasync_Otto_Job_Status::enqueue_pending($route)) {
271 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', 0, 'batch_overflow');
272 + $deferred++;
273 + } else {
274 + $rejected++;
275 + }
276 + continue;
277 + }
278 +
149 279 # Queue the per-URL processing for background execution. The handler
150 280 # (metasync_handle_otto_crawl_url_job) performs transient warming,
151 281 # SEO meta sync, and per-URL host cache purge.
152 282 # Offset each event by $key seconds to avoid wp_schedule_single_event()
153 283 # silently dropping duplicates when timestamp + hook + args collide.
154 - wp_schedule_single_event($now + $key, 'metasync_process_otto_crawl_url_job', array($route));
155 -
156 - $routes_to_process[] = $route;
157 - $scheduled_count++;
284 + # A false return means cron refused the event — fall back to the
285 + # durable queue rather than acknowledging a URL nobody will process.
286 + if (metasync_otto_schedule_single_event($now + $key, 'metasync_process_otto_crawl_url_job', array($route))) {
287 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', 0, '');
288 + $accepted++;
289 + $scheduled_this_batch++;
290 + } elseif (Metasync_Otto_Job_Status::enqueue_pending($route)) {
291 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', 0, 'cron_refused');
292 + $deferred++;
293 + } else {
294 + $rejected++;
295 + }
158 296 }
159 297
160 - # Schedule a single batch job for cache warming and edge CDN purge.
161 - # These operations benefit from batching: warm_urls() uses concurrent
162 - # fire-and-forget requests, and edge providers (Cloudflare, Fastly)
163 - # support multi-URL purge in a single API call.
298 + # Schedule a single batch job for the edge CDN purge.
299 + # The batch waits for per-URL stragglers (retries) before purging, so a
300 + # retried URL's fresh content is what reaches the edge. Multi-URL purge
301 + # in a single API call keeps edge-provider request counts low.
164 302 if (!empty($routes_to_process)) {
165 303 $batch_time = $now + count($data['urls']) + 5; // run after all per-URL jobs
166 - wp_schedule_single_event($batch_time, 'metasync_process_otto_batch_cache_job', array($routes_to_process));
304 + metasync_otto_schedule_single_event($batch_time, 'metasync_process_otto_batch_cache_job', array($routes_to_process));
167 305 }
168 306
169 - # Track OTTO optimization event in GA4 (local, non-blocking)
170 - try {
171 - Metasync_GA4::get_instance()->track_otto_optimization($data);
172 - } catch (Exception $e) {
173 - // Analytics tracking failed, continue
307 + # Work off any overflow (or cron-refused URLs) from the durable queue.
308 + if ($deferred > 0) {
309 + metasync_otto_schedule_single_event($now + 120, 'metasync_otto_pending_drainer', array());
174 310 }
175 311
176 - # Return 200 immediately so the webhook caller does not time out.
312 + # Return 200 immediately so the webhook caller does not time out, with an
313 + # honest accounting of what happened to the batch.
177 314 return new WP_REST_Response(array(
178 315 'success' => true,
179 316 'message' => 'OTTO crawl notification received',
317 + 'accepted' => $accepted,
318 + 'deferred' => $deferred,
319 + 'rejected' => $rejected,
180 320 ), 200);
181 321 }
182 322
183 323 /**
@@ -182,19 +322,22 @@
182 322
183 323 /**
184 324 * Background handler for a single OTTO crawl-notify URL.
185 325 *
186 - * Runs the per-URL sequence that was previously inline in metasync_otto_crawl_notify():
187 - * warm the OTTO transient cache, sync SEO post meta, and clear the per-URL host cache.
326 + * Pipeline per URL:
327 + * 1. Warm the OTTO transient cache — this is the ONE live API call the job
328 + * makes; the warmed suggestions are handed to the meta sync so the
329 + * endpoint is never fetched twice per URL.
330 + * 2. Sync SEO post meta (metasync_process_otto_seo_data) and branch on its
331 + * outcome: only SUCCESS / NO_CHANGE reach the purge step.
332 + * 3. Clear the per-URL host cache and re-warm it.
188 333 *
189 - * Cache warming and edge CDN purge are handled separately by
190 - * metasync_handle_otto_batch_cache_job() in a single batched event, because
191 - * those operations benefit from multi-URL batching (fewer API calls).
334 + * Retryable outcomes (API timeout / 5xx / 429 / lock contention / CPU deferral)
335 + * retry with exponential backoff up to METASYNC_OTTO_JOB_MAX_RETRIES; the
336 + * exhaustion event is recorded via metasync_record_failed_action() for
337 + * Site Health. Permanent outcomes (not connected, excluded/404 URL, API auth
338 + * or input error) never retry and never purge.
192 339 *
193 - * On failure, retries up to METASYNC_OTTO_JOB_MAX_RETRIES times with 60s backoff.
194 - * After all retries are exhausted the failure is recorded via
195 - * metasync_record_failed_action() for surfacing in Site Health.
196 - *
197 340 * @param string $route Fully-qualified URL to process.
198 341 * @param int $retry_count Current retry attempt (0 = first run).
199 342 */
200 343 function metasync_handle_otto_crawl_url_job($route = '', $retry_count = 0) {
@@ -202,117 +345,249 @@
202 345 // event may therefore invoke this callback without its required route.
203 346 // Docblock type isn't enforced at runtime; a stale cron record can pass a non-string.
204 347 // @phpstan-ignore-next-line function.alreadyNarrowedType
205 348 if (!is_string($route) || $route === '') {
206 - error_log('MetaSync OTTO: skipping crawl-url job without a valid route.');
349 + 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
207 350 return;
208 351 }
209 352
353 + $max_retries = defined('METASYNC_OTTO_JOB_MAX_RETRIES') ? METASYNC_OTTO_JOB_MAX_RETRIES : 3;
354 +
355 + # Shared retry/terminal bookkeeping so every failure path stays consistent.
356 + $schedule_retry = function ($reason) use ($route, $retry_count, $max_retries) {
357 + if ($retry_count < $max_retries) {
358 + # Exponential backoff (60s, 120s, 240s) plus jitter so a batch of
359 + # failures cannot stampede the API on the same tick.
360 + $delay = 60 * pow(2, $retry_count) + wp_rand(0, 15);
361 + $scheduled = metasync_otto_schedule_single_event(time() + $delay, 'metasync_process_otto_crawl_url_job', array($route, $retry_count + 1));
362 + if (!$scheduled && Metasync_Otto_Job_Status::enqueue_pending($route)) {
363 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', $retry_count + 1, $reason . '/cron_refused');
364 + } else {
365 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_RETRYING, 'job', $retry_count + 1, $reason);
366 + }
367 + 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
368 + } else {
369 + metasync_record_failed_action('metasync_process_otto_crawl_url_job');
370 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'job', $retry_count, 'exhausted:' . $reason);
371 + 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
372 + }
373 + };
374 +
210 375 try {
211 - # Step 1: Warm OTTO transient cache (fetch fresh suggestions from OTTO API into WP transient)
376 + # Not connected: retrying cannot help, and there is nothing to purge.
212 377 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
213 - if (!empty($otto_uuid)) {
214 - $transient_cache = new Metasync_Otto_Transient_Cache($otto_uuid);
215 - $transient_cache->warm_cache($route);
378 + # A corrupted option row can hold a non-string UUID; the docblock
379 + # contract alone does not enforce that at runtime.
380 + # @phpstan-ignore-next-line function.alreadyNarrowedType
381 + if (!is_string($otto_uuid) || $otto_uuid === '') {
382 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'job', $retry_count, 'not_connected');
383 + return;
216 384 }
217 385
386 + # Step 1: Warm OTTO transient cache (fetch fresh suggestions from OTTO
387 + # API into WP transient — the rate limiter, circuit breaker, and
388 + # request lock all live inside this call).
389 + $transient_cache = new Metasync_Otto_Transient_Cache($otto_uuid);
390 + $warmed = $transient_cache->warm_cache($route);
391 + if ($warmed === false) {
392 + # A 4xx refusal (bad key, revoked project) is permanent: retrying
393 + # cannot change the answer, so fail fast instead of burning the
394 + # attempt budget on warm calls that will keep being rejected.
395 + if ($transient_cache->last_failure_is_permanent()) {
396 + metasync_record_failed_action('metasync_process_otto_crawl_url_job');
397 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'job', $retry_count, 'permanent:warm_rejected');
398 + 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
399 + return;
400 + }
401 + # Transient API failure (timeout / 5xx / breaker open). No meta
402 + # write, no purge — straight to the retry path.
403 + $schedule_retry('warm_failed');
404 + return;
405 + }
406 +
218 407 # Step 2: Write SEO post meta synchronously BEFORE the cache purge.
219 - # This ensures the DB is fully up-to-date when Kinsta (or any host) re-populates
220 - # the cache on the very next request — eliminating the race condition where the
221 - # 1-second scheduled job hadn't run yet and stale meta got cached.
222 - # allow_defer=false: do NOT reschedule a new metasync_process_seo_job cron
223 - # event from this synchronous path — the crawl_url_job retry mechanism already
224 - # handles failures, and rescheduling here caused an unbounded cron pile-up.
225 - metasync_process_otto_seo_data($route, false);
408 + # This ensures the DB is fully up-to-date when the host re-populates
409 + # the cache on the very next request. The warmed suggestions are
410 + # passed through so the sync makes NO second API call.
411 + # allow_defer=false: the crawl_url_job retry mechanism owns failure
412 + # handling here; letting the sync self-reschedule caused an
413 + # unbounded cron pile-up.
414 + # warm_cache() may hand back a non-array truthy on contract drift;
415 + # the sync treats that as "no prefetched suggestions".
416 + # @phpstan-ignore-next-line function.alreadyNarrowedType
417 + $prefetched = is_array($warmed) ? $warmed : null;
418 + $outcome = metasync_process_otto_seo_data($route, false, 0, $prefetched);
226 419
227 - # Step 3: Clear the per-URL cache entry
420 + if ($outcome === Metasync_Otto_Job_Status::OUTCOME_RETRYABLE
421 + || $outcome === Metasync_Otto_Job_Status::OUTCOME_DEFERRED) {
422 + $schedule_retry('outcome_' . $outcome);
423 + return;
424 + }
425 +
426 + if ($outcome !== Metasync_Otto_Job_Status::OUTCOME_SUCCESS
427 + && $outcome !== Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE) {
428 + # Permanent failure: no retry, no purge — caches must keep serving
429 + # the last good content.
430 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'meta', $retry_count, 'permanent:' . $outcome);
431 + return;
432 + }
433 +
434 + # Step 3: meta is current (changed or confirmed unchanged) — clear the
435 + # per-URL host cache entry and re-warm it so OTTO-modified output is
436 + # what gets stored.
228 437 $otto_pixel = new Metasync_otto_pixel(false);
229 438 $otto_pixel->refresh_cache($route);
230 439 Metasync_Cache_Purge::purge_single_url($route);
440 + Metasync_Cache_Purge::warm_urls(array($route));
231 441
232 - } catch (Exception $e) {
233 - $max_retries = defined('METASYNC_OTTO_JOB_MAX_RETRIES') ? METASYNC_OTTO_JOB_MAX_RETRIES : 3;
442 + # A URL that completed on a retry missed its batch's edge purge (the
443 + # batch only waits so long). Late completers purge the edge themselves.
444 + if ($retry_count > 0) {
445 + Metasync_Edge_Cache_Purge::purge(array($route));
446 + }
234 447
235 - if ($retry_count < $max_retries) {
236 - # Schedule a retry with exponential backoff: 60s, 120s, 240s
237 - $delay = 60 * pow(2, $retry_count);
238 - wp_schedule_single_event(time() + $delay, 'metasync_process_otto_crawl_url_job', array($route, $retry_count + 1));
239 - error_log('MetaSync OTTO: retrying crawl-url job for ' . $route . ' (attempt ' . ($retry_count + 1) . '/' . $max_retries . ') in ' . $delay . 's: ' . $e->getMessage());
240 - } else {
241 - metasync_record_failed_action('metasync_process_otto_crawl_url_job');
242 - error_log('MetaSync OTTO: background crawl-url job permanently failed for ' . $route . ' after ' . $max_retries . ' retries: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
448 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_COMPLETED, 'job', $retry_count, $outcome);
449 +
450 + } catch (\Throwable $e) {
451 + # Cron must survive both Exception and PHP 7+ Error failures. Always use
452 + # the same bounded retry path, and emit structured context without the URL
453 + # payload or credentials. The retry is booked BEFORE the log attempt so a
454 + # failing logger can never swallow the retry; logging is best-effort.
455 + $schedule_retry('throwable:' . get_class($e));
456 + if (class_exists('Metasync_Error_Logger')) {
457 + try {
458 + Metasync_Error_Logger::log(
459 + Metasync_Error_Logger::CATEGORY_OTTO_RENDER,
460 + Metasync_Error_Logger::SEVERITY_ERROR,
461 + 'OTTO crawl-url job threw; retry policy applied',
462 + [
463 + 'operation' => 'crawl_url_job',
464 + 'outcome' => 'throwable',
465 + 'retry_count' => (int) $retry_count,
466 + 'exception' => get_class($e),
467 + ]
468 + );
469 + } catch (\Throwable $logger_error) {
470 + # Logging must never kill the handler after the retry is booked.
471 + error_log('MetaSync OTTO: crawl-url job error log failed: ' . $logger_error->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
472 + }
243 473 }
244 474 }
245 475 }
246 476
247 477 /**
248 - * Batch handler for cache warming and edge CDN purge.
478 + * Batch handler for the edge CDN purge.
249 479 *
250 - * Runs after all per-URL jobs have had time to complete. Batching these
251 - * operations avoids N separate API calls to edge providers (Cloudflare
252 - * supports up to 30 tags per request) and lets warm_urls() issue concurrent
253 - * fire-and-forget requests.
480 + * Runs after all per-URL jobs have had time to complete. Per-URL host caches
481 + * are cleared and re-warmed by the individual jobs; this batched event only
482 + * handles the edge CDN purge, which benefits from multi-URL API calls
483 + * (Cloudflare, Fastly, Akamai, Sucuri, Sevella, etc.).
254 484 *
255 - * @param array $routes List of fully-qualified URLs to warm and purge.
485 + * URLs still retrying are stragglers: the batch waits (up to
486 + * METASYNC_OTTO_BATCH_MAX_WAITS reschedules 60s apart) so their freshly
487 + * synced content — not stale content — reaches the edge. URLs that complete
488 + * after the wait budget purge the edge themselves from the retry path.
489 + *
490 + * @param array $routes List of fully-qualified URLs in this batch.
491 + * @param int $wait_count How many times this batch has already waited.
256 492 */
257 -function metasync_handle_otto_batch_cache_job($routes = array()) {
493 +function metasync_handle_otto_batch_cache_job($routes = array(), $wait_count = 0) {
258 494 // WP-Cron events persist independently of plugin code. A stale or malformed
259 495 // event may therefore invoke this callback without its required route list.
260 496 // Docblock type isn't enforced at runtime; a stale cron record can pass a non-array.
261 497 // @phpstan-ignore-next-line function.alreadyNarrowedType
262 498 if (!is_array($routes) || empty($routes)) {
263 - error_log('MetaSync OTTO: skipping batch cache job without valid routes.');
499 + 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
264 500 return;
265 501 }
266 502
503 + $max_waits = defined('METASYNC_OTTO_BATCH_MAX_WAITS') ? METASYNC_OTTO_BATCH_MAX_WAITS : 3;
504 + // Docblock type isn't enforced at runtime; a stale cron record can pass a non-numeric.
505 + // @phpstan-ignore-next-line function.alreadyNarrowedType
506 + $wait_count = is_numeric($wait_count) ? (int) $wait_count : 0;
507 +
508 + # Split the batch: terminally-done URLs vs. URLs with work still in
509 + # flight. Unknown-state URLs (legacy events scheduled before the status
510 + # store existed, or deduped duplicates whose entry expired) count as done
511 + # — their per-URL job has either finished or never will, and both mean
512 + # the edge purge should not stall behind them.
513 + $ready = array();
514 + $stragglers = 0;
515 + foreach ($routes as $route) {
516 + $entry = Metasync_Otto_Job_Status::get($route);
517 + $state = is_array($entry) && isset($entry['state']) ? $entry['state'] : null;
518 + if ($state === Metasync_Otto_Job_Status::STATE_RETRYING
519 + || $state === Metasync_Otto_Job_Status::STATE_ACCEPTED) {
520 + $stragglers++;
521 + } else {
522 + # completed / failed / unknown: nothing left to wait for.
523 + $ready[] = $route;
524 + }
525 + }
526 +
527 + # Still live work in the batch and wait budget left: hold the edge purge.
528 + if ($stragglers > 0 && $wait_count < $max_waits) {
529 + metasync_otto_schedule_single_event(time() + 60, 'metasync_process_otto_batch_cache_job', array($routes, $wait_count + 1));
530 + return;
531 + }
532 +
533 + if (empty($ready)) {
534 + return;
535 + }
536 +
267 537 try {
268 - # Re-populate the cache so OTTO-modified output is what gets stored.
269 - # Per-URL host cache was already cleared by individual jobs.
270 - # warm_urls() hits each URL with a non-blocking request so our code —
271 - # with fresh transients and post meta — is first to populate the host cache.
272 - Metasync_Cache_Purge::warm_urls($routes);
273 -
274 538 # Purge edge CDN caches (Cloudflare, Fastly, Akamai, Sucuri, Sevalla, etc.)
275 539 # Tag-based providers purge only the affected posts; full-flush providers fire once per batch.
276 - Metasync_Edge_Cache_Purge::purge($routes);
540 + Metasync_Edge_Cache_Purge::purge($ready);
277 541 } catch (Exception $e) {
278 542 metasync_record_failed_action('metasync_process_otto_batch_cache_job');
279 - error_log('MetaSync OTTO: batch cache job failed for ' . count($routes) . ' URLs: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
543 + 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
280 544 }
281 545 }
282 546
283 -# NOTE: Cache system removed - these functions are no longer needed
284 -# Kept for backward compatibility in case old cache directories need cleanup
285 -function metasync_deleteDir($dir) {
286 - if (!is_dir($dir)) {
287 - return false;
547 +/**
548 + * Drainer: work off URLs that could not be scheduled inline (batch overflow
549 + * or a cron that refused the event) from the durable pending queue.
550 + *
551 + * Processes at most 25 URLs per run to stay friendly to WP-Cron, schedules
552 + * the same batched edge purge as the webhook path for the chunk it handled,
553 + * and reschedules itself while the queue is non-empty. URLs it takes off
554 + * the queue are scheduled unconditionally unless a real cron event already
555 + * exists for them — the webhook's overflow bookkeeping must never veto,
556 + * because by then the URL has already left the durable queue.
557 + */
558 +function metasync_handle_otto_pending_drainer() {
559 + $chunk = Metasync_Otto_Job_Status::take_pending(25);
560 + if (empty($chunk)) {
561 + return;
288 562 }
289 - $files = array_diff(scandir($dir), array('.', '..'));
290 - foreach ($files as $file) {
291 - $filePath = $dir . DIRECTORY_SEPARATOR . $file;
292 - if (is_dir($filePath)) {
293 - metasync_deleteDir($filePath);
563 +
564 + $now = time();
565 + $scheduled = 0;
566 + foreach ($chunk as $key => $route) {
567 + # This run owns the chunk: take_pending() already removed these URLs
568 + # from the durable queue, so skipping here would drop them. Only a
569 + # real cron event vetoes — the webhook's overflow ledger row must
570 + # not (see metasync_otto_job_event_pending).
571 + if (metasync_otto_job_event_pending($route)) {
572 + continue;
573 + }
574 + if (metasync_otto_schedule_single_event($now + $key, 'metasync_process_otto_crawl_url_job', array($route))) {
575 + Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'drain', 0, '');
576 + $scheduled++;
294 577 } else {
295 - unlink($filePath);
578 + # Cron still refusing: put it back at the end of the queue.
579 + Metasync_Otto_Job_Status::enqueue_pending($route);
296 580 }
297 581 }
298 - return rmdir($dir);
299 -}
300 582
301 -# Cleanup function for removing old cache directories (if they exist)
302 -function metasync_invalidate_all_caches($folder = ''){
303 - # Cache system removed - this function only exists to clean up old cache directories
304 - if(!defined('WP_CONTENT_DIR')){
305 - return false;
583 + if ($scheduled > 0) {
584 + metasync_otto_schedule_single_event($now + count($chunk) + 5, 'metasync_process_otto_batch_cache_job', array($chunk));
306 585 }
307 - $wp_content_dir = WP_CONTENT_DIR;
308 - $cache_dir = $wp_content_dir . '/metasync_caches';
309 - if(in_array($folder, ['posts', 'pages'])){
310 - $cache_dir = $cache_dir . '/' . $folder;
586 +
587 + if (Metasync_Otto_Job_Status::pending_count() > 0) {
588 + metasync_otto_schedule_single_event($now + 60, 'metasync_otto_pending_drainer', array());
311 589 }
312 - if(is_dir($cache_dir)){
313 - metasync_deleteDir($cache_dir);
314 - }
315 590 }
316 591
317 592 // metasync_is_custom_or_lps_page() now lives in includes/metasync-helpers.php
318 593 // (loaded unconditionally before this file) so all SEO surfaces share one rule.
@@ -337,15 +612,15 @@
337 612 */
338 613 function metasync_is_elementor_editor_request() {
339 614 # The preview iframe loads the front end with ?elementor-preview=POST_ID.
340 615 # This is the request OTTO would otherwise buffer and corrupt.
341 - if (isset($_GET['elementor-preview'])) {
616 + if (isset($_GET['elementor-preview'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
342 617 return true;
343 618 }
344 619
345 620 # Elementor editor / app entry points carried as an action on the front end.
346 - if (isset($_REQUEST['action'])
347 - && in_array($_REQUEST['action'], array('elementor', 'elementor_ajax'), true)
621 + if (isset($_REQUEST['action']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
622 + && in_array($_REQUEST['action'], array('elementor', 'elementor_ajax'), true) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
348 623 ) {
349 624 return true;
350 625 }
351 626
@@ -389,8 +664,24 @@
389 664 if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
390 665 return;
391 666 }
392 667
668 + # Never run OTTO on WordPress core draft/pending previews
669 + # (?p=<ID>&preview=true, with preview_id/preview_nonce on the full link).
670 + # Previews are per-user, non-cacheable, and routed purely by query string —
671 + # and OTTO's route identity ignores the query string, so a processed preview
672 + # is answered with the route's public page (the homepage for /?p=…) instead
673 + # of the draft the editor asked for. Skip early and let WP render it.
674 + if (
675 + (isset($_GET['preview']) && $_GET['preview'] === 'true') || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
676 + !empty($_GET['preview_id']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
677 + ) {
678 + if (!defined('DONOTCACHEPAGE')) {
679 + define('DONOTCACHEPAGE', true);
680 + }
681 + return;
682 + }
683 +
393 684 # Never run OTTO on the Elementor editor/preview iframe. It is a logged-in
394 685 # front-end GET (not is_admin()), so it slips past the admin guard below;
395 686 # buffering and rewriting it breaks the editor canvas ("fails to load").
396 687 if (metasync_is_elementor_editor_request()) {
@@ -423,9 +714,9 @@
423 714 # then lower-case and strip surrounding slashes.
424 715 $otto_req_path = explode('?', (string) wp_unslash($_SERVER['REQUEST_URI'] ?? ''), 2)[0];
425 716 $otto_req_path = strtolower(trim($otto_req_path, '/'));
426 717 # Strip the site's base path so first-segment matching works on subdir installs.
427 - $otto_home_path = trim((string) parse_url(home_url(), PHP_URL_PATH), '/');
718 + $otto_home_path = trim((string) wp_parse_url(home_url(), PHP_URL_PATH), '/');
428 719 if ($otto_home_path !== '' && strpos($otto_req_path, $otto_home_path . '/') === 0) {
429 720 $otto_req_path = substr($otto_req_path, strlen($otto_home_path) + 1);
430 721 }
431 722 $otto_first_segment = explode('/', $otto_req_path, 2)[0];
@@ -443,11 +734,11 @@
443 734 # ──────────────────────────────────────────────────────────────────────
444 735
445 736 if (
446 737 # disable ajax calls
447 - isset($_GET['ucfrontajaxaction']) ||
738 + isset($_GET['ucfrontajaxaction']) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
448 739 # OTTO Preview mode - skip OTTO when previewing original content
449 - (isset($_GET['otto_preview']) && $_GET['otto_preview'] === '1') ||
740 + (isset($_GET['otto_preview']) && $_GET['otto_preview'] === '1') || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
450 741 # WooCommerce shop archive page only (products and categories now use SSR)
451 742 //(function_exists('is_shop') && is_shop()) ||
452 743 # Cart page
453 744 (function_exists('is_cart') && is_cart()) ||
@@ -459,32 +750,32 @@
459 750 (function_exists('wp_doing_ajax') && wp_doing_ajax()) ||
460 751 # check by constant
461 752 (defined('DOING_AJAX') && DOING_AJAX) ||
462 753 # WooCommerce AJAX endpoint (e.g., ?wc-ajax=update_cart)
463 - (isset($_REQUEST['wc-ajax']) && !empty($_REQUEST['wc-ajax'])) ||
754 + (isset($_REQUEST['wc-ajax']) && !empty($_REQUEST['wc-ajax'])) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
464 755 # AJAX requests via X-Requested-With header
465 756 (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') ||
466 757 # Gravity Forms submission detection - skip OTTO to allow form processing
467 - (isset($_POST['gform_submit']) && (
468 - is_array($_POST['gform_submit']) ||
469 - (is_string($_POST['gform_submit']) && isset($_POST['is_submit_' . $_POST['gform_submit']]) && !empty($_POST['gform_submit']))
758 + (isset($_POST['gform_submit']) && ( // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
759 + is_array($_POST['gform_submit']) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
760 + (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
470 761 )) ||
471 762 # Gravity Forms AJAX submission
472 - (isset($_POST['gform_ajax']) && isset($_POST['gform_submit']) && (
473 - is_array($_POST['gform_submit']) || !empty($_POST['gform_submit'])
763 + (isset($_POST['gform_ajax']) && isset($_POST['gform_submit']) && ( // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
764 + is_array($_POST['gform_submit']) || !empty($_POST['gform_submit']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
474 765 )) ||
475 766 # Gravity Forms file upload
476 - (isset($_POST['gform_uploaded_files'])) ||
767 + (isset($_POST['gform_uploaded_files'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
477 768 # Any Gravity Forms POST parameter
478 - (isset($_POST['gform_submit']) || isset($_POST['gform_unique_id']) || isset($_POST['gform_field_values'])) ||
769 + (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
479 770 # Formidable Forms AJAX submission detection - skip OTTO to allow form processing
480 - (isset($_POST['action']) && $_POST['action'] === 'frm_entries_create') ||
771 + (isset($_POST['action']) && $_POST['action'] === 'frm_entries_create') || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
481 772 # Formidable Forms POST parameters
482 - (isset($_POST['form_id']) && !empty($_POST['form_id'])) ||
773 + (isset($_POST['form_id']) && !empty($_POST['form_id'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
483 774 # Formidable Forms action parameter
484 - (isset($_POST['frm_action']) && !empty($_POST['frm_action'])) ||
775 + (isset($_POST['frm_action']) && !empty($_POST['frm_action'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
485 776 # Formidable Forms item_key (used in form submissions)
486 - (isset($_POST['item_key']) && !empty($_POST['item_key']))
777 + (isset($_POST['item_key']) && !empty($_POST['item_key'])) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
487 778 ) {
488 779 return;
489 780 }
490 781
@@ -534,12 +825,14 @@
534 825 return;
535 826 }
536 827
537 828 # Protocol paths and static assets that are never OTTO-able content.
538 - # class_exists guard matches how this function treats the render strategy
539 - # further down: if the class is missing we fail open and let the request
540 - # proceed rather than fatal.
541 - if (class_exists('Metasync_Otto_Render_Strategy')
829 + # The guard fails open — if the strategy is unavailable we let the request
830 + # proceed rather than fatal. metasync_otto_class_provides() rather than a
831 + # bare class_exists(): a partially updated install can leave an older copy of
832 + # the strategy class loaded that predates is_non_content_path(), which
833 + # satisfies class_exists() and then fatals on every front-end request.
834 + if (metasync_otto_class_provides('Metasync_Otto_Render_Strategy', 'is_non_content_path')
542 835 && Metasync_Otto_Render_Strategy::is_non_content_path($request_path)) {
543 836 return;
544 837 }
545 838
@@ -551,16 +844,45 @@
551 844 if ( $detection['is_bot'] ) {
552 845 $bot_detector->push_crawl_log_to_sa( $detection, $current_url );
553 846 }
554 847
555 - # Throttle OTTO rendering for non-search-engine bots: at most one render
556 - # per URL+bot every 5 minutes. Humans and verified search engines
557 - # (Googlebot, Bingbot, etc.) are never throttled — their hits always get
558 - # full OTTO content so indexing remains current. SEO tools, AI scrapers,
559 - # uptime monitors, and unverified crawlers are de-duplicated so repeat
560 - # hits don't re-trigger the expensive DOM-rewriting path.
848 + # A headless frontend renders the public HTML, so stop after the cheap cache
849 + # protections and crawl telemetry, before any OTTO rendering work begins.
850 + #
851 + # Routed through metasync_headless_is_active() rather than calling the class
852 + # directly: Metasync_Headless_Config resolves only through the committed
853 + # Composer classmap, so a partially updated install can leave this newer
854 + # caller beside a missing or older copy with no fallback lookup path. This
855 + # function is hooked to `wp`, so an unguarded static call is a white screen
856 + # on every front-end page view. The helper treats an unresolvable class as
857 + # "not headless", which keeps OTTO behaving as it did before headless mode.
858 + # @phpstan-ignore-next-line function.alreadyNarrowedType
859 + $metasync_is_headless = function_exists('metasync_headless_is_active')
860 + ? metasync_headless_is_active()
861 + : (class_exists('Metasync_Headless_Config') && Metasync_Headless_Config::is_active());
862 +
863 + if ($metasync_is_headless) {
864 + return;
865 + }
866 +
867 + # Throttle OTTO rendering for bots that are not exempt: at most one render
868 + # per URL+bot every 5 minutes. Humans, search engines (Googlebot, Bingbot,
869 + # Yahoo's Slurp, Sogou, Exabot, Applebot, ...), host cache warmers and
870 + # synthetic performance auditors are never throttled — their hits always get
871 + # full OTTO content, so indexing stays current, the page cache can actually
872 + # be filled, and audit tools measure what a real visitor sees. SEO tools,
873 + # social crawlers, archivers, AI scrapers, uptime monitors and unrecognized
874 + # crawlers are de-duplicated so repeat hits don't re-trigger the expensive
875 + # DOM-rewriting path.
876 + #
877 + # Two complementary gates, deliberately kept separate:
878 + # - is_unthrottled_category() consults the classifier's explicit per-bot
879 + # category map, so the exempt set is defined in exactly one place.
880 + # - metasync_otto_is_unthrottled_infrastructure_agent() matches the raw
881 + # user-agent (plus a query marker and a site filter), which catches
882 + # preload agents that have no entry in the map at all.
561 883 if ( $detection['is_bot']
562 - && $detection['bot_type'] !== 'search_engine'
884 + && ! Metasync_Otto_Bot_Detector::is_unthrottled_category( $detection['bot_type'] )
563 885 && ! metasync_otto_is_unthrottled_infrastructure_agent( $detection ) ) {
564 886 $normalized_url = strtok( $current_url, '?' );
565 887 if ( $normalized_url === false ) {
566 888 $normalized_url = $current_url;
@@ -611,9 +933,9 @@
611 933 return;
612 934 }
613 935
614 936 # OPTIMIZED: Check if Otto should be disabled for WP Rocket compatibility
615 - if (class_exists('WP_Rocket')) {
937 + if (defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket')) {
616 938 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
617 939
618 940 if ($wp_rocket_compat_mode === 'disable_otto') {
619 941 return; # Exit early, Otto is disabled when WP Rocket is active
@@ -619,14 +941,25 @@
619 941 return; # Exit early, Otto is disabled when WP Rocket is active
620 942 }
621 943 }
622 944
623 - # Skip OTTO for Divi AJAX pagination and paginated archive requests.
624 - # ?et_blog = Divi AJAX pagination callback
945 + # Skip OTTO for Divi AJAX pagination, and for paginated archive requests
946 + # on Divi-built sites.
947 + # ?et_blog = Divi AJAX pagination callback — always skipped, on every
948 + # theme, because Divi fires it regardless of the site layout.
625 949 # /page/N/ = paginated blog/archive pages — OTTO's buffer/HTTP render causes
626 950 # module numbering mismatch between page 1 (with TB template) and page N
627 951 # (without TB template), breaking Divi's JS pagination selector matching.
628 - if (isset($_GET['et_blog']) || (is_paged() && !is_singular())) {
952 + # That mismatch is a Divi rendering bug, so the /page/N/ skip is gated on
953 + # Divi detection: non-Divi sites (Elementor + the7, ...) keep OTTO on real
954 + # paginated archives. Detection covers the Divi theme and its child themes
955 + # (get_template() returns the PARENT template dir, "Divi" either way), the
956 + # Extra theme and the Divi Builder plugin (both define ET_BUILDER_VERSION
957 + # / et_setup_theme — same TB machinery, same bug).
958 + $is_divi = defined('ET_BUILDER_VERSION')
959 + || function_exists('et_setup_theme')
960 + || wp_get_theme()->get_template() === 'Divi';
961 + if (isset($_GET['et_blog']) || ($is_divi && is_paged() && !is_singular())) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
629 962 return;
630 963 }
631 964
632 965 # Skip OTTO on search-results pages. A search request carries its
@@ -641,9 +974,9 @@
641 974 # correct pre-rendered search page to everyone else. Search-results pages
642 975 # have no per-canonical-URL OTTO suggestions of their own, so OTTO must never
643 976 # run here. The raw $_GET['s'] check is a defensive fallback for setups where
644 977 # the main query is altered before this point.
645 - if (is_search() || !empty($_GET['s'])) {
978 + if (is_search() || !empty($_GET['s'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
646 979 return;
647 980 }
648 981
649 982 # Handle cache plugin compatibility early - before any caching happens
@@ -695,9 +1028,9 @@
695 1028 $otto_has_fetch_detector = class_exists('Metasync_Otto_Render_Strategy') && method_exists('Metasync_Otto_Render_Strategy', 'is_internal_fetch');
696 1029
697 1030 $otto_is_internal_fetch = $otto_has_fetch_detector
698 1031 ? Metasync_Otto_Render_Strategy::is_internal_fetch()
699 - : !empty($_GET['is_otto_page_fetch']);
1032 + : !empty($_GET['is_otto_page_fetch']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only loopback marker, request-scoped filters only
700 1033
701 1034 if($otto_is_internal_fetch){
702 1035
703 1036 # Block SEO plugins NOW for this internal fetch request
@@ -702,10 +1035,10 @@
702 1035
703 1036 # Block SEO plugins NOW for this internal fetch request
704 1037 # metasync_otto_block_seo_plugins();
705 1038 # $_SERVER['REQUEST_URI'] = remove_query_arg('is_otto_page_fetch', $_SERVER['REQUEST_URI']);
706 - $block_title = !empty($_GET['otto_block_title']) && $_GET['otto_block_title'] === '1';
707 - $block_description = !empty($_GET['otto_block_desc']) && $_GET['otto_block_desc'] === '1';
1039 + $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
1040 + $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
708 1041
709 1042 # Block SEO plugins conditionally based on what Otto has
710 1043 if ($block_title || $block_description) {
711 1044 metasync_otto_block_seo_plugins($block_title, $block_description);
@@ -741,8 +1074,19 @@
741 1074
742 1075 # get the otto uuid
743 1076 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
744 1077
1078 + # Upgrade-window guard: the bundled DOM parser resolves only through the
1079 + # otto/vendor autoloader required at the top of this file. While an update
1080 + # is mid-copy (or a stale opcache classmap is still resident), that
1081 + # dependency can briefly be undefined here, and constructing the render
1082 + # stack below would throw a class-not-found Error and take the whole page
1083 + # down with it. Degrade to an OTTO-less page for this request instead —
1084 + # the next request, on the settled tree, renders normally.
1085 + if ( ! class_exists( 'simplehtmldom\HtmlDocument' ) ) {
1086 + return;
1087 + }
1088 +
745 1089 # start the class
746 1090 $otto = new Metasync_otto_pixel($otto_uuid);
747 1091
748 1092 # call render
@@ -758,9 +1102,9 @@
758 1102 # Detect active plugins. SG Optimizer detection now lives in
759 1103 # metasync_otto_disable_sg_page_cache(), which is invoked later
760 1104 # once OTTO confirms suggestions for the URL.
761 1105 $brizy_active = class_exists('Brizy_Editor') || defined('BRIZY_VERSION');
762 - $wp_rocket_active = class_exists('WP_Rocket');
1106 + $wp_rocket_active = defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket');
763 1107
764 1108 # OPTIMIZED: Get configuration option using cached config
765 1109 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
766 1110
@@ -774,9 +1118,9 @@
774 1118 if ($cached !== false) {
775 1119 $has_brizy_posts = ($cached === 'yes');
776 1120 } else {
777 1121 # Query database only if cache missed
778 - $has_brizy_posts = $wpdb->get_var(
1122 + $has_brizy_posts = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- feature-detection probe for Brizy posts — postmeta flag scan has no WordPress API
779 1123 "SELECT COUNT(*) FROM {$wpdb->postmeta}
780 1124 WHERE meta_key = 'brizy_post_uid'
781 1125 AND meta_value != ''
782 1126 LIMIT 1"
@@ -833,9 +1177,9 @@
833 1177 * Only meaningful on SiteGround sites without WP Rocket; a no-op otherwise.
834 1178 */
835 1179 function metasync_otto_disable_sg_page_cache() {
836 1180 # Only relevant when SG Optimizer is active and WP Rocket is not present.
837 - if (class_exists('WP_Rocket')) {
1181 + if (defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket')) {
838 1182 return;
839 1183 }
840 1184 if (!is_plugin_active('sg-cachepress/sg-cachepress.php')) {
841 1185 return;
@@ -1144,8 +1488,29 @@
1144 1488 }
1145 1489 }
1146 1490
1147 1491 /**
1492 + * Whether MetaSync is still emitting Open Graph / Twitter tags.
1493 + *
1494 + * The Social Media & Open Graph switch stops MetaSync writing those tags on
1495 + * every render path, so the suppression below has to stand down with it —
1496 + * removing another plugin's og:/twitter: tag while contributing none of our own
1497 + * would leave the page with no social tags at all.
1498 + *
1499 + * The class_exists() guard keeps this usable if the flags file has not loaded
1500 + * yet; treating that window as "enabled" preserves the previous behaviour.
1501 + *
1502 + * @return bool
1503 + */
1504 +function metasync_otto_social_output_enabled() {
1505 + if (!class_exists('Metasync_Feature_Flags')) {
1506 + return true;
1507 + }
1508 +
1509 + return Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::SOCIAL_OG);
1510 +}
1511 +
1512 +/**
1148 1513 * Block SEO plugins conditionally based on what Otto is providing
1149 1514 * Only blocks title if Otto has title, only blocks description if Otto has description
1150 1515 * This prevents duplicate SEO tags while allowing fallback to SEO plugins when Otto has no data.
1151 1516 * Supports Yoast SEO, Rank Math, and AIOSEO (free + pro).
@@ -1185,11 +1550,19 @@
1185 1550
1186 1551
1187 1552 # Remove description presenters only when OTTO has a description
1188 1553 if ($block_description) {
1554 + # The plain meta description belongs to the SEO title/description
1555 + # feature, which has its own setting.
1189 1556 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Meta_Description_Presenter';
1190 - $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Open_Graph\Description_Presenter';
1191 - $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Twitter\Description_Presenter';
1557 +
1558 + # The OG/Twitter descriptions are social tags. With that feature
1559 + # switched off MetaSync writes none of its own, so Yoast's must be
1560 + # left alone rather than removed with nothing to replace them.
1561 + if (metasync_otto_social_output_enabled()) {
1562 + $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Open_Graph\Description_Presenter';
1563 + $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Twitter\Description_Presenter';
1564 + }
1192 1565 }
1193 1566
1194 1567 foreach ($presenters as $key => $presenter) {
1195 1568 // Safely get class name, suppressing autoload errors
@@ -1207,11 +1580,13 @@
1207 1580 # Disable Rank Math
1208 1581 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
1209 1582 is_plugin_active('seo-by-rankmath/rank-math.php')) {
1210 1583
1211 - if ($block_title) {
1212 - add_filter('rank_math/frontend/title', '__return_empty_string', 999);
1213 - }
1584 + # TITLE: Never blank Rank Math's title output during SSR fetch.
1585 + # rank_math/frontend/title also gates Rank Math's og:title/twitter:title
1586 + # output, so forcing it empty blanks those social tags along with the
1587 + # document title. Let Rank Math render its own title/og/twitter tags;
1588 + # OTTO's replace_title() and the dedup/precedence pass clean up duplicates.
1214 1589
1215 1590 if ($block_description) {
1216 1591 add_filter('rank_math/frontend/description', '__return_false', 999);
1217 1592 add_filter('rank_math/frontend/show_keywords', '__return_false', 999);
@@ -1222,17 +1597,22 @@
1222 1597 if (is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
1223 1598 is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
1224 1599
1225 1600 if ($block_title) {
1601 + # The document title belongs to the SEO title feature, which has its
1602 + # own setting; only the og:/twitter: unsets below are social.
1226 1603 add_filter('aioseo_title', '__return_empty_string', 999);
1227 - add_filter('aioseo_facebook_tags', function($meta) {
1228 - if (is_array($meta)) { unset($meta['og:title']); }
1229 - return $meta;
1230 - }, 999);
1231 - add_filter('aioseo_twitter_tags', function($meta) {
1232 - if (is_array($meta)) { unset($meta['twitter:title']); }
1233 - return $meta;
1234 - }, 999);
1604 +
1605 + if (metasync_otto_social_output_enabled()) {
1606 + add_filter('aioseo_facebook_tags', function($meta) {
1607 + if (is_array($meta)) { unset($meta['og:title']); }
1608 + return $meta;
1609 + }, 999);
1610 + add_filter('aioseo_twitter_tags', function($meta) {
1611 + if (is_array($meta)) { unset($meta['twitter:title']); }
1612 + return $meta;
1613 + }, 999);
1614 + }
1235 1615 }
1236 1616
1237 1617 if ($block_description) {
1238 1618 # Use granular tag list when available to only block what OTTO provides
@@ -1237,10 +1617,12 @@
1237 1617 if ($block_description) {
1238 1618 # Use granular tag list when available to only block what OTTO provides
1239 1619 $tags = !empty($description_tags) ? $description_tags : [];
1240 1620 $block_standard = empty($tags) || in_array('meta[name=description]', $tags);
1241 - $block_og_desc = empty($tags) || in_array('meta[property=og:description]', $tags);
1242 - $block_tw_desc = empty($tags) || in_array('meta[name=twitter:description]', $tags);
1621 + # Social tags only get blocked while MetaSync is still emitting them.
1622 + $social_enabled = metasync_otto_social_output_enabled();
1623 + $block_og_desc = $social_enabled && (empty($tags) || in_array('meta[property=og:description]', $tags));
1624 + $block_tw_desc = $social_enabled && (empty($tags) || in_array('meta[name=twitter:description]', $tags));
1243 1625
1244 1626 if ($block_standard) {
1245 1627 add_filter('aioseo_description', '__return_empty_string', 999);
1246 1628 }
@@ -1338,8 +1720,17 @@
1338 1720
1339 1721 # Handle AJAX Clear Cache request
1340 1722 # NOTE: Cache system removed - this is now a no-op
1341 1723 function metasync_clear_otto_cache_handler() {
1724 + # State-changing admin action: verify the nonce sent by admin/js
1725 + # (clear_otto_caches) and confirm plugin access before clearing the
1726 + # detection transient or queueing the loopback re-check.
1727 + check_ajax_referer('metasync_clear_otto_cache', 'nonce');
1728 +
1729 + if (!Metasync::current_user_has_plugin_access()) {
1730 + wp_send_json_error(['message' => 'Not authorized']);
1731 + }
1732 +
1342 1733 if (!empty($_GET['clear_otto_cache'])) {
1343 1734 delete_transient('metasync_otto_js_detected');
1344 1735 # Re-run the JS detection in the background so the notice refreshes.
1345 1736 metasync_schedule_otto_js_check();
@@ -1383,15 +1774,21 @@
1383 1774 add_action('wp', 'metasync_start_otto');
1384 1775
1385 1776 # ENHANCED OTTO SEO INTEGRATION
1386 1777 # Register async SEO processing hook
1387 - add_action('metasync_process_seo_job', 'metasync_process_otto_seo_data', 10, 3);
1778 + add_action('metasync_process_seo_job', 'metasync_process_otto_seo_data', 10, 4);
1388 1779 add_action('metasync_process_otto_crawl_url_job', 'metasync_handle_otto_crawl_url_job', 10, 2);
1389 - add_action('metasync_process_otto_batch_cache_job', 'metasync_handle_otto_batch_cache_job');
1780 + add_action('metasync_process_otto_batch_cache_job', 'metasync_handle_otto_batch_cache_job', 10, 2);
1781 + add_action('metasync_otto_pending_drainer', 'metasync_handle_otto_pending_drainer');
1390 1782
1391 1783 # Process OTTO SEO data and update WordPress meta fields for SEO plugins
1392 1784 # This function now runs asynchronously via WordPress cron system
1393 1785 #
1786 + # Returns one of the Metasync_Otto_Job_Status::OUTCOME_* strings so callers
1787 + # can distinguish success, confirmed no-change, retryable failure, permanent
1788 + # failure, and CPU deferral — a bare boolean cannot drive correct retry and
1789 + # purge decisions.
1790 + #
1394 1791 # @param string $route Fully-qualified URL to process.
1395 1792 # @param bool $allow_defer When false, skip CPU-deferral rescheduling (used when
1396 1793 # called synchronously from crawl_url_job whose own retry
1397 1794 # mechanism already handles failures).
@@ -1396,10 +1793,14 @@
1396 1793 # called synchronously from crawl_url_job whose own retry
1397 1794 # mechanism already handles failures).
1398 1795 # @param int $deferral_count How many times this job has already been deferred for
1399 1796 # CPU load. Prevents infinite reschedule loops.
1797 + # @param array|null $prefetched_seo_data Suggestions already fetched by the transient
1798 + # warm step. When an array it is used as-is so the job
1799 + # makes exactly ONE API call per URL; the endpoint is
1800 + # only fetched here when no prefetch was provided.
1400 1801
1401 -function metasync_process_otto_seo_data($route, $allow_defer = true, $deferral_count = 0) {
1802 +function metasync_process_otto_seo_data($route, $allow_defer = true, $deferral_count = 0, $prefetched_seo_data = null) {
1402 1803 # Maximum number of times a job can be deferred before it is dropped.
1403 1804 $max_deferrals = defined('METASYNC_SEO_JOB_MAX_DEFERRALS') ? METASYNC_SEO_JOB_MAX_DEFERRALS : 5;
1404 1805 $lock_key = null;
1405 1806 $lock_acquired = false;
@@ -1406,9 +1807,9 @@
1406 1807
1407 1808 try {
1408 1809 # Validate input
1409 1810 if (empty($route) || !is_string($route)) {
1410 - return false;
1811 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1411 1812 }
1412 1813
1413 1814 # CPU load check — defer if server is under load.
1414 1815 # Only reschedule when called from the cron hook (allow_defer=true) and
@@ -1417,14 +1818,14 @@
1417 1818 # acquire-then-immediately-release it (which defeats concurrency protection).
1418 1819 if (!Metasync_CPU_Monitor::is_load_safe()) {
1419 1820 if ($allow_defer) {
1420 1821 if ($deferral_count < $max_deferrals) {
1421 - wp_schedule_single_event(
1822 + metasync_otto_schedule_single_event(
1422 1823 time() + 60,
1423 1824 'metasync_process_seo_job',
1424 1825 array($route, true, $deferral_count + 1)
1425 1826 );
1426 - return false;
1827 + return Metasync_Otto_Job_Status::OUTCOME_DEFERRED;
1427 1828 }
1428 1829 # deferral budget exhausted — RUN the job late instead
1429 1830 # of dropping it. The write is idempotent and bounded, and a
1430 1831 # late sync is strictly better than SEO data that never
@@ -1430,11 +1831,12 @@
1430 1831 # late sync is strictly better than SEO data that never
1431 1832 # arrives. No new cron events are created, so the
1432 1833 # anti-pile-up guarantee is preserved. Fall through.
1433 1834 } else {
1434 - # allow_defer=false (sync path): return false without creating
1435 - # cron events — OTTO's next crawl re-triggers this URL.
1436 - return false;
1835 + # allow_defer=false (sync path): report retryable without creating
1836 + # cron events — the crawl-url job's retry mechanism (or OTTO's
1837 + # next crawl) re-triggers this URL.
1838 + return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
1437 1839 }
1438 1840 }
1439 1841
1440 1842 # Concurrency lock: prevent multiple SEO jobs from running simultaneously.
@@ -1443,15 +1845,15 @@
1443 1845 # processing this URL — reschedule once with a short delay instead of stacking.
1444 1846 $lock_key = 'metasync_seo_lock_' . md5($route);
1445 1847 if (get_transient($lock_key) !== false) {
1446 1848 if ($allow_defer && $deferral_count < $max_deferrals) {
1447 - wp_schedule_single_event(
1849 + metasync_otto_schedule_single_event(
1448 1850 time() + 30,
1449 1851 'metasync_process_seo_job',
1450 1852 array($route, true, $deferral_count + 1)
1451 1853 );
1452 1854 }
1453 - return false;
1855 + return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
1454 1856 }
1455 1857 set_transient($lock_key, true, 120);
1456 1858 $lock_acquired = true;
1457 1859
@@ -1460,16 +1862,15 @@
1460 1862
1461 1863 # Skip excluded URLs - don't process SEO data for them
1462 1864 if (metasync_is_otto_url_excluded($route)) {
1463 1865 //error_log('MetaSync OTTO: Skipping SEO processing for excluded URL: ' . $route);
1464 - return false;
1866 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1465 1867 }
1466 1868
1467 1869 # Pre-flight 404 check: exclude URLs that would return 404 before making API call
1468 1870 if (!metasync_otto_is_url_available($route)) {
1469 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route}");
1470 1871 metasync_otto_auto_exclude_404_url($route);
1471 - return false;
1872 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1472 1873 }
1473 1874
1474 1875 # Get OTTO UUID from settings
1475 1876 # OPTIMIZED: Use cached options
@@ -1475,37 +1876,37 @@
1475 1876 # OPTIMIZED: Use cached options
1476 1877 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
1477 1878
1478 1879 if (empty($otto_uuid)) {
1479 - return false;
1880 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1480 1881 }
1481 1882
1482 1883 # Meta descriptions are always enabled by default - no check needed
1483 1884
1484 - # Fetch SEO data from OTTO API
1485 - $seo_data = metasync_fetch_otto_seo_data($route, $otto_uuid);
1486 -
1487 - if (!$seo_data) {
1488 - metasync_record_failed_action( 'metasync_process_seo_job' );
1489 - return false;
1885 + # Obtain the OTTO suggestions for this URL. When the caller already
1886 + # warmed the transient cache (crawl-url job), those suggestions are
1887 + # handed in and NO second API call is made — the endpoint, rate
1888 + # limiter, and breaker are only hit once per URL.
1889 + # An empty array is a legitimate answer ("OTTO holds nothing for this
1890 + # URL") and must not be treated as a fetch failure; only a strict
1891 + # false from the fetcher is a failure.
1892 + if (is_array($prefetched_seo_data)) {
1893 + $seo_data = $prefetched_seo_data;
1894 + } else {
1895 + $failure_class = null;
1896 + $seo_data = metasync_fetch_otto_seo_data($route, $otto_uuid, $failure_class);
1897 + if ($seo_data === false) {
1898 + if ($failure_class === Metasync_Otto_Job_Status::OUTCOME_PERMANENT) {
1899 + # Auth/input-level rejection from the API — retrying the
1900 + # same request cannot help.
1901 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1902 + }
1903 + # Timeout / 5xx / 429 / empty body / bad JSON: retryable.
1904 + metasync_record_failed_action( 'metasync_process_seo_job' );
1905 + return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
1906 + }
1490 1907 }
1491 1908
1492 - # Mark this URL as crawled by OTTO for SSR
1493 - # Extract domain and path from route
1494 - $parsed_url = parse_url($route);
1495 - $domain_with_scheme = ($parsed_url['scheme'] ?? 'https') . '://' . ($parsed_url['host'] ?? '');
1496 - $url_path = ($parsed_url['path'] ?? '/');
1497 -
1498 - # Create crawl data structure
1499 - $crawl_data = array(
1500 - 'domain' => $domain_with_scheme,
1501 - 'urls' => array($url_path)
1502 - );
1503 -
1504 - # Load Otto pixel class and save crawl data
1505 - $otto_pixel = new Metasync_otto_pixel($otto_uuid);
1506 - $otto_pixel->save_crawl_data($crawl_data);
1507 -
1508 1909 # Get WordPress post ID from URL
1509 1910 $post_id = url_to_postid($route);
1510 1911
1511 1912 # Special handling for WooCommerce shop page (url_to_postid doesn't work for it)
@@ -1525,9 +1926,9 @@
1525 1926
1526 1927 # Try to find WooCommerce product by URL if url_to_postid failed
1527 1928 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
1528 1929 # Extract product slug from URL
1529 - $product_slug = basename(parse_url($route, PHP_URL_PATH));
1930 + $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1530 1931
1531 1932 # Try to get product by slug
1532 1933 $products = wc_get_products(array(
1533 1934 'name' => $product_slug,
@@ -1558,17 +1959,16 @@
1558 1959 if (!$post_id || $post_id <= 0) {
1559 1960 # Check if this is a category page
1560 1961 if (strpos($route, '/category/') !== false) {
1561 1962 # Extract category slug from URL
1562 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
1963 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1563 1964 $category = get_category_by_slug($category_slug);
1564 1965
1565 1966 if ($category) {
1566 1967 # Check if category would return 404 before applying OTTO changes
1567 1968 if (metasync_would_term_return_404($category->term_id, 'category', $route)) {
1568 - error_log("MetaSync OTTO: Skipping SEO processing for category that would return 404: {$route} (Category ID: {$category->term_id})");
1569 1969 metasync_otto_auto_exclude_404_url($route);
1570 - return false;
1970 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1571 1971 }
1572 1972
1573 1973 # Update comprehensive category SEO meta fields
1574 1974 $update_result = metasync_update_comprehensive_category_seo_fields($category->term_id, $seo_data);
@@ -1631,8 +2031,12 @@
1631 2031 $title = 'Category Structured Data Update';
1632 2032 break;
1633 2033 }
1634 2034
2035 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2036 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2037 + }
2038 +
1635 2039 if (!empty($title)) {
1636 2040 metasync_log_sync_history([
1637 2041 'title' => $title,
1638 2042 'source' => 'OTTO SEO',
@@ -1648,12 +2052,12 @@
1648 2052 ]);
1649 2053 }
1650 2054 }
1651 2055
1652 - return true;
2056 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
1653 2057 }
1654 2058
1655 - return false;
2059 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
1656 2060 }
1657 2061 }
1658 2062
1659 2063 # Check if this is a WooCommerce product category
@@ -1658,17 +2062,16 @@
1658 2062
1659 2063 # Check if this is a WooCommerce product category
1660 2064 if (strpos($route, '/product-category/') !== false) {
1661 2065 # Extract product category slug from URL
1662 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2066 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1663 2067 $term = get_term_by('slug', $category_slug, 'product_cat');
1664 2068
1665 2069 if ($term && !is_wp_error($term)) {
1666 2070 # Check if product category would return 404 before applying OTTO changes
1667 2071 if (metasync_would_term_return_404($term->term_id, 'product_cat', $route)) {
1668 - error_log("MetaSync OTTO: Skipping SEO processing for product category that would return 404: {$route} (Term ID: {$term->term_id})");
1669 2072 metasync_otto_auto_exclude_404_url($route);
1670 - return false;
2073 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1671 2074 }
1672 2075
1673 2076 # Update comprehensive taxonomy SEO meta fields
1674 2077 $update_result = metasync_update_comprehensive_taxonomy_seo_fields($term->term_id, 'product_cat', $seo_data);
@@ -1731,8 +2134,12 @@
1731 2134 $title = 'Product Category Structured Data Update';
1732 2135 break;
1733 2136 }
1734 2137
2138 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2139 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2140 + }
2141 +
1735 2142 if (!empty($title)) {
1736 2143 metasync_log_sync_history([
1737 2144 'title' => $title,
1738 2145 'source' => 'OTTO SEO',
@@ -1749,12 +2156,12 @@
1749 2156 ]);
1750 2157 }
1751 2158 }
1752 2159
1753 - return true;
2160 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
1754 2161 }
1755 2162
1756 - return false;
2163 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
1757 2164 }
1758 2165 }
1759 2166
1760 2167 # Check if this is the home page (landing page)
@@ -1775,11 +2182,10 @@
1775 2182
1776 2183 if ($home_page) {
1777 2184 # Check if home page would return 404 before applying OTTO changes
1778 2185 if (metasync_would_page_return_404($home_page->ID, $route)) {
1779 - 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})");
1780 2186 metasync_otto_auto_exclude_404_url($route);
1781 - return false;
2187 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1782 2188 }
1783 2189
1784 2190 # Update comprehensive home page SEO meta fields
1785 2191 $update_result = metasync_update_comprehensive_seo_fields($home_page->ID, $seo_data);
@@ -1845,8 +2251,12 @@
1845 2251 $title = 'Home Page Structured Data Update';
1846 2252 break;
1847 2253 }
1848 2254
2255 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2256 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2257 + }
2258 +
1849 2259 if (!empty($title)) {
1850 2260 metasync_log_sync_history([
1851 2261 'title' => $title,
1852 2262 'source' => 'OTTO SEO',
@@ -1861,12 +2271,12 @@
1861 2271 ]);
1862 2272 }
1863 2273 }
1864 2274
1865 - return true;
2275 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
1866 2276 }
1867 2277
1868 - return false;
2278 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
1869 2279 }
1870 2280 }
1871 2281
1872 2282 # Check if this is the blog/posts page (page_for_posts)
@@ -1877,11 +2287,10 @@
1877 2287
1878 2288 if ($posts_page && $route_clean === $posts_page_url) {
1879 2289 # Check if blog page would return 404
1880 2290 if (metasync_would_page_return_404($posts_page->ID, $route)) {
1881 - 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})");
1882 2291 metasync_otto_auto_exclude_404_url($route);
1883 - return false;
2292 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1884 2293 }
1885 2294
1886 2295 # Update comprehensive blog page SEO meta fields
1887 2296 $update_result = metasync_update_comprehensive_seo_fields($posts_page->ID, $seo_data);
@@ -1947,8 +2356,12 @@
1947 2356 $title = 'Blog Page Structured Data Update';
1948 2357 break;
1949 2358 }
1950 2359
2360 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2361 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2362 + }
2363 +
1951 2364 if (!empty($title)) {
1952 2365 metasync_log_sync_history([
1953 2366 'title' => $title,
1954 2367 'source' => 'OTTO SEO',
@@ -1963,12 +2376,12 @@
1963 2376 ]);
1964 2377 }
1965 2378 }
1966 2379
1967 - return true;
2380 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
1968 2381 }
1969 2382
1970 - return false;
2383 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
1971 2384 }
1972 2385 }
1973 2386
1974 2387 # URL didn't resolve to any supported entity (post, category, home page, blog page)
@@ -1973,12 +2386,11 @@
1973 2386
1974 2387 # URL didn't resolve to any supported entity (post, category, home page, blog page)
1975 2388 # Treat as 404 and auto-exclude (e.g. deleted post, non-existent page)
1976 2389 if (!metasync_otto_is_url_available($route)) {
1977 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404 (no matching entity): {$route}");
1978 2390 metasync_otto_auto_exclude_404_url($route);
1979 2391 }
1980 - return false;
2392 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1981 2393 }
1982 2394
1983 2395 # Verify this is actually a post, page, or WooCommerce product
1984 2396 $post = get_post($post_id);
@@ -1987,16 +2399,15 @@
1987 2399 $supported_post_types = metasync_get_supported_post_types();
1988 2400
1989 2401 if (!$post || !in_array($post->post_type, $supported_post_types)) {
1990 2402 # Skip unsupported post types
1991 - return false;
2403 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1992 2404 }
1993 2405
1994 2406 # Check if page would return 404 before applying OTTO changes
1995 2407 if (metasync_would_page_return_404($post_id, $route)) {
1996 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route} (Post ID: {$post_id}, Status: {$post->post_status})");
1997 2408 metasync_otto_auto_exclude_404_url($route);
1998 - return false;
2409 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1999 2410 }
2000 2411
2001 2412 # Update comprehensive SEO meta fields
2002 2413 $update_result = metasync_update_comprehensive_seo_fields($post_id, $seo_data);
@@ -2062,8 +2473,12 @@
2062 2473 $title = 'Structured Data Update';
2063 2474 break;
2064 2475 }
2065 2476
2477 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2478 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2479 + }
2480 +
2066 2481 if (!empty($title)) {
2067 2482 metasync_log_sync_history([
2068 2483 'title' => $title,
2069 2484 'source' => 'OTTO SEO',
@@ -2078,16 +2493,16 @@
2078 2493 ]);
2079 2494 }
2080 2495 }
2081 2496
2082 - return true;
2497 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
2083 2498 }
2084 2499
2085 - return false;
2500 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
2086 2501
2087 2502 } catch (Exception $e) {
2088 2503 metasync_record_failed_action( 'metasync_process_seo_job' );
2089 - return false;
2504 + return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
2090 2505 } finally {
2091 2506 # Release the concurrency lock only if we actually acquired it.
2092 2507 # Early returns (CPU deferral, lock contention) must NOT delete a lock
2093 2508 # that another process may be holding.
@@ -2108,11 +2523,10 @@
2108 2523
2109 2524 // Minimal duplicate prevention within short time window
2110 2525 if (!empty($data['title']) && !empty($data['source'])) {
2111 2526 global $wpdb;
2112 - $table = $wpdb->prefix . Metasync_Sync_History_Database::$table_name;
2113 - $recent = $wpdb->get_var($wpdb->prepare(
2114 - "SELECT COUNT(*) FROM `$table` WHERE title = %s AND source = %s AND created_at >= %s",
2527 + $recent = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- sync-history duplicate guard over the plugin custom table — write-path freshness required
2528 + "SELECT COUNT(*) FROM `{$wpdb->prefix}metasync_sync_history` WHERE title = %s AND source = %s AND created_at >= %s",
2115 2529 $data['title'],
2116 2530 $data['source'],
2117 2531 gmdate('Y-m-d H:i:s', time() - 60)
2118 2532 ));
@@ -2123,9 +2537,9 @@
2123 2537
2124 2538 $sync_db->add($data);
2125 2539
2126 2540 } catch (Exception $e) {
2127 - error_log("MetaSync: Failed to log sync history: " . $e->getMessage());
2541 + 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
2128 2542 }
2129 2543 }
2130 2544
2131 2545 /**
@@ -2201,9 +2615,9 @@
2201 2615 $db = new Metasync_Redirection_Database();
2202 2616 $redirect = new Metasync_Redirection($db);
2203 2617 return $redirect->resolve_url_to_final_destination($url, 10);
2204 2618 } catch (Exception $e) {
2205 - error_log('MetaSync OTTO: Redirect resolution failed for ' . $url . ' - ' . $e->getMessage());
2619 + 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
2206 2620 return $url;
2207 2621 }
2208 2622 }
2209 2623
@@ -2234,9 +2648,9 @@
2234 2648 'status' => 'active',
2235 2649 'auto_excluded' => 1,
2236 2650 ]);
2237 2651 } catch (Exception $e) {
2238 - error_log('MetaSync OTTO: Failed to auto-exclude 404 URL: ' . $url . ' - ' . $e->getMessage());
2652 + 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
2239 2653 return false;
2240 2654 }
2241 2655 }
2242 2656
@@ -2256,14 +2670,13 @@
2256 2670 try {
2257 2671 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2258 2672 $db = new Metasync_Otto_Excluded_URLs_Database();
2259 2673 global $wpdb;
2260 - $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
2261 2674 # Normalize the same way is_url_excluded() does
2262 2675 $url_normalized = rtrim(trim($url), '/');
2263 - $records = $wpdb->get_results(
2676 + $records = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- webhook-path cleanup of auto-exclusions over the plugin custom table
2264 2677 $wpdb->prepare(
2265 - "SELECT id FROM `{$table}` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'",
2678 + "SELECT id FROM `{$wpdb->prefix}metasync_otto_excluded_urls` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'",
2266 2679 $url_normalized
2267 2680 )
2268 2681 );
2269 2682 if (!empty($records)) {
@@ -2293,24 +2706,26 @@
2293 2706 }
2294 2707 try {
2295 2708 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2296 2709 global $wpdb;
2297 - $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
2298 2710 $url_normalized = rtrim(trim($url), '/');
2299 2711
2300 2712 $records = get_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY);
2301 2713
2302 2714 if ($records === false) {
2303 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- no user input, table name from $wpdb->prefix
2304 - $records = $wpdb->get_results(
2305 - "SELECT url_pattern, pattern_type FROM `{$table}` WHERE status = 'active' AND (auto_excluded = 0 OR auto_excluded IS NULL) ORDER BY created_at DESC"
2715 + $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
2716 + "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"
2306 2717 );
2307 2718
2308 2719 // Graceful recovery: auto_excluded column missing on pre-v2.7.4 installs.
2309 - // Run ALTER TABLE to add it and treat URL as not excluded so OTTO continues rendering.
2720 + // Schema changes belong to the upgrade routines, never the render path:
2721 + // degrade to "not excluded" so OTTO keeps rendering, and schedule a
2722 + // one-shot background repair that runs the same column migration off
2723 + // the visitor request (handled by metasync_repair_otto_excluded_schema).
2310 2724 if ($wpdb->last_error && strpos($wpdb->last_error, 'auto_excluded') !== false) {
2311 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
2312 - $wpdb->query("ALTER TABLE `{$table}` ADD COLUMN `auto_excluded` TINYINT(1) NOT NULL DEFAULT 0");
2725 + if (false === wp_next_scheduled('metasync_repair_otto_excluded_schema')) {
2726 + wp_schedule_single_event(time() + MINUTE_IN_SECONDS, 'metasync_repair_otto_excluded_schema');
2727 + }
2313 2728 return false;
2314 2729 }
2315 2730
2316 2731 set_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY, $records ?: [], METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL);
@@ -2386,9 +2801,9 @@
2386 2801 }
2387 2802
2388 2803 # WooCommerce product by slug
2389 2804 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
2390 - $product_slug = basename(parse_url($route, PHP_URL_PATH));
2805 + $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2391 2806 $products = wc_get_products(array('name' => $product_slug, 'limit' => 1, 'status' => 'publish'));
2392 2807 if (!empty($products)) {
2393 2808 $post_id = $products[0]->get_id();
2394 2809 } else {
@@ -2415,9 +2830,9 @@
2415 2830 }
2416 2831
2417 2832 # Category
2418 2833 if (strpos($route, '/category/') !== false) {
2419 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2834 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2420 2835 $category = get_category_by_slug($category_slug);
2421 2836 if ($category) {
2422 2837 return !metasync_would_term_return_404($category->term_id, 'category', $route);
2423 2838 }
@@ -2424,9 +2839,9 @@
2424 2839 }
2425 2840
2426 2841 # WooCommerce product category
2427 2842 if (strpos($route, '/product-category/') !== false) {
2428 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2843 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2429 2844 $term = get_term_by('slug', $category_slug, 'product_cat');
2430 2845 if ($term && !is_wp_error($term)) {
2431 2846 return !metasync_would_term_return_404($term->term_id, 'product_cat', $route);
2432 2847 }
@@ -2468,9 +2883,9 @@
2468 2883
2469 2884 $removed = 0;
2470 2885 $marked_permanent = 0;
2471 2886 $thirty_days_ago = strtotime('-30 days');
2472 - $next_recheck = date('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS);
2887 + $next_recheck = gmdate('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS);
2473 2888
2474 2889 foreach ($records as $record) {
2475 2890 $url = trim($record->url_pattern);
2476 2891 if (empty($url)) {
@@ -2491,16 +2906,10 @@
2491 2906 }
2492 2907 }
2493 2908 }
2494 2909
2495 - if ($removed > 0) {
2496 - error_log("MetaSync OTTO: Recheck 404 exclusions - removed {$removed} URL(s) that are now available");
2497 - }
2498 - if ($marked_permanent > 0) {
2499 - error_log("MetaSync OTTO: Recheck 404 exclusions - marked {$marked_permanent} URL(s) as permanent (still 404 after 30 days)");
2500 - }
2501 2910 } catch (Exception $e) {
2502 - error_log('MetaSync OTTO: Recheck 404 exclusions failed - ' . $e->getMessage());
2911 + 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
2503 2912 }
2504 2913 }
2505 2914
2506 2915 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
@@ -2505,8 +2914,24 @@
2505 2914
2506 2915 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
2507 2916
2508 2917 /**
2918 + * Background repair of the metasync_otto_excluded_urls schema (cron).
2919 + *
2920 + * Scheduled by metasync_is_otto_url_manually_excluded() when a render-path
2921 + * read fails because the auto_excluded column is missing (pre-2.7.4 install
2922 + * whose version migration did not run). Runs the DB layer's self-healing
2923 + * column migration off the visitor request instead of ALTERing inline.
2924 + */
2925 +function metasync_repair_otto_excluded_schema()
2926 +{
2927 + require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2928 + Metasync_Otto_Excluded_URLs_Database::ensure_schema();
2929 +}
2930 +
2931 +add_action('metasync_repair_otto_excluded_schema', 'metasync_repair_otto_excluded_schema');
2932 +
2933 +/**
2509 2934 * Check if a post/page would return 404 without making HTTP request
2510 2935 * Uses WordPress database checks for fast validation
2511 2936 *
2512 2937 * @param int $post_id WordPress post ID
@@ -2682,9 +3107,9 @@
2682 3107 }
2683 3108 }
2684 3109
2685 3110 // WP Cloud's warmer also tags its requests with a query marker.
2686 - if ( ! $is_infra && isset( $_GET['x-cache-engine'] ) ) {
3111 + if ( ! $is_infra && isset( $_GET['x-cache-engine'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only flag for render throttling
2687 3112 $is_infra = true;
2688 3113 }
2689 3114
2690 3115 /**