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 +618 -232 2.6.23 → 2.7.1 View file →
@@ -16,18 +16,66 @@
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';
25 48 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-render-strategy.php';
26 49 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-config.php';
27 50 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-detector.php';
28 51 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-statistics-database.php';
29 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 +
30 78 # OPTIMIZED: get the metasync options (cached in static class)
31 79 $metasync_options = Metasync_Otto_Config::get_options();
32 80
33 81 # OTTO SSR is always enabled by default
@@ -51,9 +99,9 @@
51 99 # Use endpoint manager to get the correct domain
52 100 $otto_domain = 'sa.searchatlas.com'; # default
53 101 if (class_exists('Metasync_Endpoint_Manager')) {
54 102 $otto_api_domain = Metasync_Endpoint_Manager::get_endpoint('OTTO_API_DOMAIN');
55 - $parsed = parse_url($otto_api_domain);
103 + $parsed = wp_parse_url($otto_api_domain);
56 104 if (!empty($parsed['host'])) {
57 105 $otto_domain = $parsed['host'];
58 106 }
59 107 }
@@ -69,8 +117,49 @@
69 117 echo $otto_tag;
70 118 }, 1); # Priority 1 to output early in head
71 119
72 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 +/**
73 162 * Start end point to handle requests on page updates
74 163 * The Otto Crawler will call this end point once a page is updated
75 164 **/
76 165
@@ -105,28 +194,25 @@
105 194 $otto_pixel->save_crawl_data($data);
106 195
107 196 # Defer the expensive per-URL work (OTTO API fetch, post meta sync,
108 197 # 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.
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.
112 201 #
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.
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.
118 206 $max_jobs_per_batch = defined('METASYNC_MAX_JOBS_PER_BATCH') ? METASYNC_MAX_JOBS_PER_BATCH : 25;
119 207 $now = time();
120 208 $routes_to_process = array();
121 - $scheduled_count = 0;
209 + $accepted = 0;
210 + $deferred = 0;
211 + $rejected = 0;
212 + $scheduled_this_batch = 0;
122 213
123 214 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 215 # prepare the route
130 216 $route = $data['domain'] . $url;
131 217
132 218 # validate the route
@@ -145,39 +231,71 @@
145 231 if (metasync_is_otto_url_excluded($route)) {
146 232 continue;
147 233 }
148 234
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 +
149 257 # Queue the per-URL processing for background execution. The handler
150 258 # (metasync_handle_otto_crawl_url_job) performs transient warming,
151 259 # SEO meta sync, and per-URL host cache purge.
152 260 # Offset each event by $key seconds to avoid wp_schedule_single_event()
153 261 # 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++;
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 + }
158 274 }
159 275
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.
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.
164 280 if (!empty($routes_to_process)) {
165 281 $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));
282 + metasync_otto_schedule_single_event($batch_time, 'metasync_process_otto_batch_cache_job', array($routes_to_process));
167 283 }
168 284
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
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());
174 288 }
175 289
176 - # Return 200 immediately so the webhook caller does not time out.
290 + # Return 200 immediately so the webhook caller does not time out, with an
291 + # honest accounting of what happened to the batch.
177 292 return new WP_REST_Response(array(
178 293 'success' => true,
179 294 'message' => 'OTTO crawl notification received',
295 + 'accepted' => $accepted,
296 + 'deferred' => $deferred,
297 + 'rejected' => $rejected,
180 298 ), 200);
181 299 }
182 300
183 301 /**
@@ -182,19 +300,22 @@
182 300
183 301 /**
184 302 * Background handler for a single OTTO crawl-notify URL.
185 303 *
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.
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.
188 311 *
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).
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.
192 317 *
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 318 * @param string $route Fully-qualified URL to process.
198 319 * @param int $retry_count Current retry attempt (0 = first run).
199 320 */
200 321 function metasync_handle_otto_crawl_url_job($route = '', $retry_count = 0) {
@@ -202,117 +323,227 @@
202 323 // event may therefore invoke this callback without its required route.
203 324 // Docblock type isn't enforced at runtime; a stale cron record can pass a non-string.
204 325 // @phpstan-ignore-next-line function.alreadyNarrowedType
205 326 if (!is_string($route) || $route === '') {
206 - error_log('MetaSync OTTO: skipping crawl-url job without a valid 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
207 328 return;
208 329 }
209 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 +
210 353 try {
211 - # Step 1: Warm OTTO transient cache (fetch fresh suggestions from OTTO API into WP transient)
354 + # Not connected: retrying cannot help, and there is nothing to purge.
212 355 $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);
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;
216 362 }
217 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 +
218 385 # 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);
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);
226 397
227 - # 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.
228 415 $otto_pixel = new Metasync_otto_pixel(false);
229 416 $otto_pixel->refresh_cache($route);
230 417 Metasync_Cache_Purge::purge_single_url($route);
418 + Metasync_Cache_Purge::warm_urls(array($route));
231 419
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 +
232 428 } catch (Exception $e) {
233 - $max_retries = defined('METASYNC_OTTO_JOB_MAX_RETRIES') ? METASYNC_OTTO_JOB_MAX_RETRIES : 3;
234 -
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());
243 - }
429 + $schedule_retry('exception:' . $e->getMessage());
244 430 }
245 431 }
246 432
247 433 /**
248 - * Batch handler for cache warming and edge CDN purge.
434 + * Batch handler for the edge CDN purge.
249 435 *
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.
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.).
254 440 *
255 - * @param array $routes List of fully-qualified URLs to warm and purge.
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.
256 448 */
257 -function metasync_handle_otto_batch_cache_job($routes = array()) {
449 +function metasync_handle_otto_batch_cache_job($routes = array(), $wait_count = 0) {
258 450 // WP-Cron events persist independently of plugin code. A stale or malformed
259 451 // event may therefore invoke this callback without its required route list.
260 452 // Docblock type isn't enforced at runtime; a stale cron record can pass a non-array.
261 453 // @phpstan-ignore-next-line function.alreadyNarrowedType
262 454 if (!is_array($routes) || empty($routes)) {
263 - error_log('MetaSync OTTO: skipping batch cache job without valid 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
264 456 return;
265 457 }
266 458
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;
463 +
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 +
267 493 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 494 # Purge edge CDN caches (Cloudflare, Fastly, Akamai, Sucuri, Sevalla, etc.)
275 495 # Tag-based providers purge only the affected posts; full-flush providers fire once per batch.
276 - Metasync_Edge_Cache_Purge::purge($routes);
496 + Metasync_Edge_Cache_Purge::purge($ready);
277 497 } catch (Exception $e) {
278 498 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());
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
280 500 }
281 501 }
282 502
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;
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;
288 518 }
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);
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++;
294 533 } else {
295 - unlink($filePath);
534 + # Cron still refusing: put it back at the end of the queue.
535 + Metasync_Otto_Job_Status::enqueue_pending($route);
296 536 }
297 537 }
298 - return rmdir($dir);
299 -}
300 538
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;
539 + if ($scheduled > 0) {
540 + metasync_otto_schedule_single_event($now + count($chunk) + 5, 'metasync_process_otto_batch_cache_job', array($chunk));
306 541 }
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;
542 +
543 + if (Metasync_Otto_Job_Status::pending_count() > 0) {
544 + metasync_otto_schedule_single_event($now + 60, 'metasync_otto_pending_drainer', array());
311 545 }
312 - if(is_dir($cache_dir)){
313 - metasync_deleteDir($cache_dir);
314 - }
315 546 }
316 547
317 548 // metasync_is_custom_or_lps_page() now lives in includes/metasync-helpers.php
318 549 // (loaded unconditionally before this file) so all SEO surfaces share one rule.
@@ -337,15 +568,15 @@
337 568 */
338 569 function metasync_is_elementor_editor_request() {
339 570 # The preview iframe loads the front end with ?elementor-preview=POST_ID.
340 571 # This is the request OTTO would otherwise buffer and corrupt.
341 - if (isset($_GET['elementor-preview'])) {
572 + if (isset($_GET['elementor-preview'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
342 573 return true;
343 574 }
344 575
345 576 # 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)
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
348 579 ) {
349 580 return true;
350 581 }
351 582
@@ -389,8 +620,24 @@
389 620 if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
390 621 return;
391 622 }
392 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.
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 +
393 640 # Never run OTTO on the Elementor editor/preview iframe. It is a logged-in
394 641 # front-end GET (not is_admin()), so it slips past the admin guard below;
395 642 # buffering and rewriting it breaks the editor canvas ("fails to load").
396 643 if (metasync_is_elementor_editor_request()) {
@@ -423,9 +670,9 @@
423 670 # then lower-case and strip surrounding slashes.
424 671 $otto_req_path = explode('?', (string) wp_unslash($_SERVER['REQUEST_URI'] ?? ''), 2)[0];
425 672 $otto_req_path = strtolower(trim($otto_req_path, '/'));
426 673 # 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), '/');
674 + $otto_home_path = trim((string) wp_parse_url(home_url(), PHP_URL_PATH), '/');
428 675 if ($otto_home_path !== '' && strpos($otto_req_path, $otto_home_path . '/') === 0) {
429 676 $otto_req_path = substr($otto_req_path, strlen($otto_home_path) + 1);
430 677 }
431 678 $otto_first_segment = explode('/', $otto_req_path, 2)[0];
@@ -443,11 +690,11 @@
443 690 # ──────────────────────────────────────────────────────────────────────
444 691
445 692 if (
446 693 # disable ajax calls
447 - isset($_GET['ucfrontajaxaction']) ||
694 + isset($_GET['ucfrontajaxaction']) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
448 695 # OTTO Preview mode - skip OTTO when previewing original content
449 - (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
450 697 # WooCommerce shop archive page only (products and categories now use SSR)
451 698 //(function_exists('is_shop') && is_shop()) ||
452 699 # Cart page
453 700 (function_exists('is_cart') && is_cart()) ||
@@ -459,32 +706,32 @@
459 706 (function_exists('wp_doing_ajax') && wp_doing_ajax()) ||
460 707 # check by constant
461 708 (defined('DOING_AJAX') && DOING_AJAX) ||
462 709 # WooCommerce AJAX endpoint (e.g., ?wc-ajax=update_cart)
463 - (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
464 711 # AJAX requests via X-Requested-With header
465 712 (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') ||
466 713 # 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']))
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
470 717 )) ||
471 718 # Gravity Forms AJAX submission
472 - (isset($_POST['gform_ajax']) && isset($_POST['gform_submit']) && (
473 - 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
474 721 )) ||
475 722 # Gravity Forms file upload
476 - (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
477 724 # Any Gravity Forms POST parameter
478 - (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
479 726 # Formidable Forms AJAX submission detection - skip OTTO to allow form processing
480 - (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
481 728 # Formidable Forms POST parameters
482 - (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
483 730 # Formidable Forms action parameter
484 - (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
485 732 # Formidable Forms item_key (used in form submissions)
486 - (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
487 734 ) {
488 735 return;
489 736 }
490 737
@@ -534,12 +781,14 @@
534 781 return;
535 782 }
536 783
537 784 # 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')
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')
542 791 && Metasync_Otto_Render_Strategy::is_non_content_path($request_path)) {
543 792 return;
544 793 }
545 794
@@ -551,16 +800,45 @@
551 800 if ( $detection['is_bot'] ) {
552 801 $bot_detector->push_crawl_log_to_sa( $detection, $current_url );
553 802 }
554 803
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.
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.
561 839 if ( $detection['is_bot']
562 - && $detection['bot_type'] !== 'search_engine'
840 + && ! Metasync_Otto_Bot_Detector::is_unthrottled_category( $detection['bot_type'] )
563 841 && ! metasync_otto_is_unthrottled_infrastructure_agent( $detection ) ) {
564 842 $normalized_url = strtok( $current_url, '?' );
565 843 if ( $normalized_url === false ) {
566 844 $normalized_url = $current_url;
@@ -624,9 +902,9 @@
624 902 # ?et_blog = Divi AJAX pagination callback
625 903 # /page/N/ = paginated blog/archive pages — OTTO's buffer/HTTP render causes
626 904 # module numbering mismatch between page 1 (with TB template) and page N
627 905 # (without TB template), breaking Divi's JS pagination selector matching.
628 - if (isset($_GET['et_blog']) || (is_paged() && !is_singular())) {
906 + if (isset($_GET['et_blog']) || (is_paged() && !is_singular())) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
629 907 return;
630 908 }
631 909
632 910 # Skip OTTO on search-results pages. A search request carries its
@@ -641,9 +919,9 @@
641 919 # correct pre-rendered search page to everyone else. Search-results pages
642 920 # have no per-canonical-URL OTTO suggestions of their own, so OTTO must never
643 921 # run here. The raw $_GET['s'] check is a defensive fallback for setups where
644 922 # the main query is altered before this point.
645 - if (is_search() || !empty($_GET['s'])) {
923 + if (is_search() || !empty($_GET['s'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
646 924 return;
647 925 }
648 926
649 927 # Handle cache plugin compatibility early - before any caching happens
@@ -695,9 +973,9 @@
695 973 $otto_has_fetch_detector = class_exists('Metasync_Otto_Render_Strategy') && method_exists('Metasync_Otto_Render_Strategy', 'is_internal_fetch');
696 974
697 975 $otto_is_internal_fetch = $otto_has_fetch_detector
698 976 ? Metasync_Otto_Render_Strategy::is_internal_fetch()
699 - : !empty($_GET['is_otto_page_fetch']);
977 + : !empty($_GET['is_otto_page_fetch']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only loopback marker, request-scoped filters only
700 978
701 979 if($otto_is_internal_fetch){
702 980
703 981 # Block SEO plugins NOW for this internal fetch request
@@ -702,10 +980,10 @@
702 980
703 981 # Block SEO plugins NOW for this internal fetch request
704 982 # metasync_otto_block_seo_plugins();
705 983 # $_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';
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
708 986
709 987 # Block SEO plugins conditionally based on what Otto has
710 988 if ($block_title || $block_description) {
711 989 metasync_otto_block_seo_plugins($block_title, $block_description);
@@ -741,8 +1019,19 @@
741 1019
742 1020 # get the otto uuid
743 1021 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
744 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 +
745 1034 # start the class
746 1035 $otto = new Metasync_otto_pixel($otto_uuid);
747 1036
748 1037 # call render
@@ -774,9 +1063,9 @@
774 1063 if ($cached !== false) {
775 1064 $has_brizy_posts = ($cached === 'yes');
776 1065 } else {
777 1066 # Query database only if cache missed
778 - $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
779 1068 "SELECT COUNT(*) FROM {$wpdb->postmeta}
780 1069 WHERE meta_key = 'brizy_post_uid'
781 1070 AND meta_value != ''
782 1071 LIMIT 1"
@@ -1144,8 +1433,29 @@
1144 1433 }
1145 1434 }
1146 1435
1147 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 +/**
1148 1458 * Block SEO plugins conditionally based on what Otto is providing
1149 1459 * Only blocks title if Otto has title, only blocks description if Otto has description
1150 1460 * This prevents duplicate SEO tags while allowing fallback to SEO plugins when Otto has no data.
1151 1461 * Supports Yoast SEO, Rank Math, and AIOSEO (free + pro).
@@ -1185,11 +1495,19 @@
1185 1495
1186 1496
1187 1497 # Remove description presenters only when OTTO has a description
1188 1498 if ($block_description) {
1499 + # The plain meta description belongs to the SEO title/description
1500 + # feature, which has its own setting.
1189 1501 $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';
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 + }
1192 1510 }
1193 1511
1194 1512 foreach ($presenters as $key => $presenter) {
1195 1513 // Safely get class name, suppressing autoload errors
@@ -1207,11 +1525,13 @@
1207 1525 # Disable Rank Math
1208 1526 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
1209 1527 is_plugin_active('seo-by-rankmath/rank-math.php')) {
1210 1528
1211 - if ($block_title) {
1212 - add_filter('rank_math/frontend/title', '__return_empty_string', 999);
1213 - }
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.
1214 1534
1215 1535 if ($block_description) {
1216 1536 add_filter('rank_math/frontend/description', '__return_false', 999);
1217 1537 add_filter('rank_math/frontend/show_keywords', '__return_false', 999);
@@ -1222,17 +1542,22 @@
1222 1542 if (is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
1223 1543 is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
1224 1544
1225 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.
1226 1548 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);
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 + }
1235 1560 }
1236 1561
1237 1562 if ($block_description) {
1238 1563 # Use granular tag list when available to only block what OTTO provides
@@ -1237,10 +1562,12 @@
1237 1562 if ($block_description) {
1238 1563 # Use granular tag list when available to only block what OTTO provides
1239 1564 $tags = !empty($description_tags) ? $description_tags : [];
1240 1565 $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);
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));
1243 1570
1244 1571 if ($block_standard) {
1245 1572 add_filter('aioseo_description', '__return_empty_string', 999);
1246 1573 }
@@ -1338,8 +1665,17 @@
1338 1665
1339 1666 # Handle AJAX Clear Cache request
1340 1667 # NOTE: Cache system removed - this is now a no-op
1341 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 +
1342 1678 if (!empty($_GET['clear_otto_cache'])) {
1343 1679 delete_transient('metasync_otto_js_detected');
1344 1680 # Re-run the JS detection in the background so the notice refreshes.
1345 1681 metasync_schedule_otto_js_check();
@@ -1383,15 +1719,21 @@
1383 1719 add_action('wp', 'metasync_start_otto');
1384 1720
1385 1721 # ENHANCED OTTO SEO INTEGRATION
1386 1722 # Register async SEO processing hook
1387 - add_action('metasync_process_seo_job', 'metasync_process_otto_seo_data', 10, 3);
1723 + add_action('metasync_process_seo_job', 'metasync_process_otto_seo_data', 10, 4);
1388 1724 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');
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');
1390 1727
1391 1728 # Process OTTO SEO data and update WordPress meta fields for SEO plugins
1392 1729 # This function now runs asynchronously via WordPress cron system
1393 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 + #
1394 1736 # @param string $route Fully-qualified URL to process.
1395 1737 # @param bool $allow_defer When false, skip CPU-deferral rescheduling (used when
1396 1738 # called synchronously from crawl_url_job whose own retry
1397 1739 # mechanism already handles failures).
@@ -1396,10 +1738,14 @@
1396 1738 # called synchronously from crawl_url_job whose own retry
1397 1739 # mechanism already handles failures).
1398 1740 # @param int $deferral_count How many times this job has already been deferred for
1399 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.
1400 1746
1401 -function metasync_process_otto_seo_data($route, $allow_defer = true, $deferral_count = 0) {
1747 +function metasync_process_otto_seo_data($route, $allow_defer = true, $deferral_count = 0, $prefetched_seo_data = null) {
1402 1748 # Maximum number of times a job can be deferred before it is dropped.
1403 1749 $max_deferrals = defined('METASYNC_SEO_JOB_MAX_DEFERRALS') ? METASYNC_SEO_JOB_MAX_DEFERRALS : 5;
1404 1750 $lock_key = null;
1405 1751 $lock_acquired = false;
@@ -1406,9 +1752,9 @@
1406 1752
1407 1753 try {
1408 1754 # Validate input
1409 1755 if (empty($route) || !is_string($route)) {
1410 - return false;
1756 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1411 1757 }
1412 1758
1413 1759 # CPU load check — defer if server is under load.
1414 1760 # Only reschedule when called from the cron hook (allow_defer=true) and
@@ -1417,14 +1763,14 @@
1417 1763 # acquire-then-immediately-release it (which defeats concurrency protection).
1418 1764 if (!Metasync_CPU_Monitor::is_load_safe()) {
1419 1765 if ($allow_defer) {
1420 1766 if ($deferral_count < $max_deferrals) {
1421 - wp_schedule_single_event(
1767 + metasync_otto_schedule_single_event(
1422 1768 time() + 60,
1423 1769 'metasync_process_seo_job',
1424 1770 array($route, true, $deferral_count + 1)
1425 1771 );
1426 - return false;
1772 + return Metasync_Otto_Job_Status::OUTCOME_DEFERRED;
1427 1773 }
1428 1774 # deferral budget exhausted — RUN the job late instead
1429 1775 # of dropping it. The write is idempotent and bounded, and a
1430 1776 # late sync is strictly better than SEO data that never
@@ -1430,11 +1776,12 @@
1430 1776 # late sync is strictly better than SEO data that never
1431 1777 # arrives. No new cron events are created, so the
1432 1778 # anti-pile-up guarantee is preserved. Fall through.
1433 1779 } 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;
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;
1437 1784 }
1438 1785 }
1439 1786
1440 1787 # Concurrency lock: prevent multiple SEO jobs from running simultaneously.
@@ -1443,15 +1790,15 @@
1443 1790 # processing this URL — reschedule once with a short delay instead of stacking.
1444 1791 $lock_key = 'metasync_seo_lock_' . md5($route);
1445 1792 if (get_transient($lock_key) !== false) {
1446 1793 if ($allow_defer && $deferral_count < $max_deferrals) {
1447 - wp_schedule_single_event(
1794 + metasync_otto_schedule_single_event(
1448 1795 time() + 30,
1449 1796 'metasync_process_seo_job',
1450 1797 array($route, true, $deferral_count + 1)
1451 1798 );
1452 1799 }
1453 - return false;
1800 + return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
1454 1801 }
1455 1802 set_transient($lock_key, true, 120);
1456 1803 $lock_acquired = true;
1457 1804
@@ -1460,16 +1807,15 @@
1460 1807
1461 1808 # Skip excluded URLs - don't process SEO data for them
1462 1809 if (metasync_is_otto_url_excluded($route)) {
1463 1810 //error_log('MetaSync OTTO: Skipping SEO processing for excluded URL: ' . $route);
1464 - return false;
1811 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1465 1812 }
1466 1813
1467 1814 # Pre-flight 404 check: exclude URLs that would return 404 before making API call
1468 1815 if (!metasync_otto_is_url_available($route)) {
1469 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route}");
1470 1816 metasync_otto_auto_exclude_404_url($route);
1471 - return false;
1817 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1472 1818 }
1473 1819
1474 1820 # Get OTTO UUID from settings
1475 1821 # OPTIMIZED: Use cached options
@@ -1475,24 +1821,40 @@
1475 1821 # OPTIMIZED: Use cached options
1476 1822 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
1477 1823
1478 1824 if (empty($otto_uuid)) {
1479 - return false;
1825 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1480 1826 }
1481 1827
1482 1828 # Meta descriptions are always enabled by default - no check needed
1483 1829
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;
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 + }
1490 1852 }
1491 1853
1492 1854 # Mark this URL as crawled by OTTO for SSR
1493 1855 # Extract domain and path from route
1494 - $parsed_url = parse_url($route);
1856 + $parsed_url = wp_parse_url($route);
1495 1857 $domain_with_scheme = ($parsed_url['scheme'] ?? 'https') . '://' . ($parsed_url['host'] ?? '');
1496 1858 $url_path = ($parsed_url['path'] ?? '/');
1497 1859
1498 1860 # Create crawl data structure
@@ -1525,9 +1887,9 @@
1525 1887
1526 1888 # Try to find WooCommerce product by URL if url_to_postid failed
1527 1889 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
1528 1890 # Extract product slug from URL
1529 - $product_slug = basename(parse_url($route, PHP_URL_PATH));
1891 + $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1530 1892
1531 1893 # Try to get product by slug
1532 1894 $products = wc_get_products(array(
1533 1895 'name' => $product_slug,
@@ -1558,17 +1920,16 @@
1558 1920 if (!$post_id || $post_id <= 0) {
1559 1921 # Check if this is a category page
1560 1922 if (strpos($route, '/category/') !== false) {
1561 1923 # Extract category slug from URL
1562 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
1924 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1563 1925 $category = get_category_by_slug($category_slug);
1564 1926
1565 1927 if ($category) {
1566 1928 # Check if category would return 404 before applying OTTO changes
1567 1929 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 1930 metasync_otto_auto_exclude_404_url($route);
1570 - return false;
1931 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1571 1932 }
1572 1933
1573 1934 # Update comprehensive category SEO meta fields
1574 1935 $update_result = metasync_update_comprehensive_category_seo_fields($category->term_id, $seo_data);
@@ -1631,8 +1992,12 @@
1631 1992 $title = 'Category Structured Data Update';
1632 1993 break;
1633 1994 }
1634 1995
1996 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
1997 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
1998 + }
1999 +
1635 2000 if (!empty($title)) {
1636 2001 metasync_log_sync_history([
1637 2002 'title' => $title,
1638 2003 'source' => 'OTTO SEO',
@@ -1648,12 +2013,12 @@
1648 2013 ]);
1649 2014 }
1650 2015 }
1651 2016
1652 - return true;
2017 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
1653 2018 }
1654 2019
1655 - return false;
2020 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
1656 2021 }
1657 2022 }
1658 2023
1659 2024 # Check if this is a WooCommerce product category
@@ -1658,17 +2023,16 @@
1658 2023
1659 2024 # Check if this is a WooCommerce product category
1660 2025 if (strpos($route, '/product-category/') !== false) {
1661 2026 # Extract product category slug from URL
1662 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2027 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1663 2028 $term = get_term_by('slug', $category_slug, 'product_cat');
1664 2029
1665 2030 if ($term && !is_wp_error($term)) {
1666 2031 # Check if product category would return 404 before applying OTTO changes
1667 2032 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 2033 metasync_otto_auto_exclude_404_url($route);
1670 - return false;
2034 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1671 2035 }
1672 2036
1673 2037 # Update comprehensive taxonomy SEO meta fields
1674 2038 $update_result = metasync_update_comprehensive_taxonomy_seo_fields($term->term_id, 'product_cat', $seo_data);
@@ -1731,8 +2095,12 @@
1731 2095 $title = 'Product Category Structured Data Update';
1732 2096 break;
1733 2097 }
1734 2098
2099 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2100 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2101 + }
2102 +
1735 2103 if (!empty($title)) {
1736 2104 metasync_log_sync_history([
1737 2105 'title' => $title,
1738 2106 'source' => 'OTTO SEO',
@@ -1749,12 +2117,12 @@
1749 2117 ]);
1750 2118 }
1751 2119 }
1752 2120
1753 - return true;
2121 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
1754 2122 }
1755 2123
1756 - return false;
2124 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
1757 2125 }
1758 2126 }
1759 2127
1760 2128 # Check if this is the home page (landing page)
@@ -1775,11 +2143,10 @@
1775 2143
1776 2144 if ($home_page) {
1777 2145 # Check if home page would return 404 before applying OTTO changes
1778 2146 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 2147 metasync_otto_auto_exclude_404_url($route);
1781 - return false;
2148 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1782 2149 }
1783 2150
1784 2151 # Update comprehensive home page SEO meta fields
1785 2152 $update_result = metasync_update_comprehensive_seo_fields($home_page->ID, $seo_data);
@@ -1845,8 +2212,12 @@
1845 2212 $title = 'Home Page Structured Data Update';
1846 2213 break;
1847 2214 }
1848 2215
2216 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2217 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2218 + }
2219 +
1849 2220 if (!empty($title)) {
1850 2221 metasync_log_sync_history([
1851 2222 'title' => $title,
1852 2223 'source' => 'OTTO SEO',
@@ -1861,12 +2232,12 @@
1861 2232 ]);
1862 2233 }
1863 2234 }
1864 2235
1865 - return true;
2236 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
1866 2237 }
1867 2238
1868 - return false;
2239 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
1869 2240 }
1870 2241 }
1871 2242
1872 2243 # Check if this is the blog/posts page (page_for_posts)
@@ -1877,11 +2248,10 @@
1877 2248
1878 2249 if ($posts_page && $route_clean === $posts_page_url) {
1879 2250 # Check if blog page would return 404
1880 2251 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 2252 metasync_otto_auto_exclude_404_url($route);
1883 - return false;
2253 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1884 2254 }
1885 2255
1886 2256 # Update comprehensive blog page SEO meta fields
1887 2257 $update_result = metasync_update_comprehensive_seo_fields($posts_page->ID, $seo_data);
@@ -1947,8 +2317,12 @@
1947 2317 $title = 'Blog Page Structured Data Update';
1948 2318 break;
1949 2319 }
1950 2320
2321 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2322 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2323 + }
2324 +
1951 2325 if (!empty($title)) {
1952 2326 metasync_log_sync_history([
1953 2327 'title' => $title,
1954 2328 'source' => 'OTTO SEO',
@@ -1963,12 +2337,12 @@
1963 2337 ]);
1964 2338 }
1965 2339 }
1966 2340
1967 - return true;
2341 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
1968 2342 }
1969 2343
1970 - return false;
2344 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
1971 2345 }
1972 2346 }
1973 2347
1974 2348 # URL didn't resolve to any supported entity (post, category, home page, blog page)
@@ -1973,12 +2347,11 @@
1973 2347
1974 2348 # URL didn't resolve to any supported entity (post, category, home page, blog page)
1975 2349 # Treat as 404 and auto-exclude (e.g. deleted post, non-existent page)
1976 2350 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 2351 metasync_otto_auto_exclude_404_url($route);
1979 2352 }
1980 - return false;
2353 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1981 2354 }
1982 2355
1983 2356 # Verify this is actually a post, page, or WooCommerce product
1984 2357 $post = get_post($post_id);
@@ -1987,16 +2360,15 @@
1987 2360 $supported_post_types = metasync_get_supported_post_types();
1988 2361
1989 2362 if (!$post || !in_array($post->post_type, $supported_post_types)) {
1990 2363 # Skip unsupported post types
1991 - return false;
2364 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1992 2365 }
1993 2366
1994 2367 # Check if page would return 404 before applying OTTO changes
1995 2368 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 2369 metasync_otto_auto_exclude_404_url($route);
1998 - return false;
2370 + return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1999 2371 }
2000 2372
2001 2373 # Update comprehensive SEO meta fields
2002 2374 $update_result = metasync_update_comprehensive_seo_fields($post_id, $seo_data);
@@ -2062,8 +2434,12 @@
2062 2434 $title = 'Structured Data Update';
2063 2435 break;
2064 2436 }
2065 2437
2438 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2439 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2440 + }
2441 +
2066 2442 if (!empty($title)) {
2067 2443 metasync_log_sync_history([
2068 2444 'title' => $title,
2069 2445 'source' => 'OTTO SEO',
@@ -2078,16 +2454,16 @@
2078 2454 ]);
2079 2455 }
2080 2456 }
2081 2457
2082 - return true;
2458 + return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
2083 2459 }
2084 2460
2085 - return false;
2461 + return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
2086 2462
2087 2463 } catch (Exception $e) {
2088 2464 metasync_record_failed_action( 'metasync_process_seo_job' );
2089 - return false;
2465 + return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
2090 2466 } finally {
2091 2467 # Release the concurrency lock only if we actually acquired it.
2092 2468 # Early returns (CPU deferral, lock contention) must NOT delete a lock
2093 2469 # that another process may be holding.
@@ -2108,11 +2484,10 @@
2108 2484
2109 2485 // Minimal duplicate prevention within short time window
2110 2486 if (!empty($data['title']) && !empty($data['source'])) {
2111 2487 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",
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",
2115 2490 $data['title'],
2116 2491 $data['source'],
2117 2492 gmdate('Y-m-d H:i:s', time() - 60)
2118 2493 ));
@@ -2123,9 +2498,9 @@
2123 2498
2124 2499 $sync_db->add($data);
2125 2500
2126 2501 } catch (Exception $e) {
2127 - 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
2128 2503 }
2129 2504 }
2130 2505
2131 2506 /**
@@ -2201,9 +2576,9 @@
2201 2576 $db = new Metasync_Redirection_Database();
2202 2577 $redirect = new Metasync_Redirection($db);
2203 2578 return $redirect->resolve_url_to_final_destination($url, 10);
2204 2579 } catch (Exception $e) {
2205 - 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
2206 2581 return $url;
2207 2582 }
2208 2583 }
2209 2584
@@ -2234,9 +2609,9 @@
2234 2609 'status' => 'active',
2235 2610 'auto_excluded' => 1,
2236 2611 ]);
2237 2612 } catch (Exception $e) {
2238 - 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
2239 2614 return false;
2240 2615 }
2241 2616 }
2242 2617
@@ -2256,14 +2631,13 @@
2256 2631 try {
2257 2632 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2258 2633 $db = new Metasync_Otto_Excluded_URLs_Database();
2259 2634 global $wpdb;
2260 - $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
2261 2635 # Normalize the same way is_url_excluded() does
2262 2636 $url_normalized = rtrim(trim($url), '/');
2263 - $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
2264 2638 $wpdb->prepare(
2265 - "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'",
2266 2640 $url_normalized
2267 2641 )
2268 2642 );
2269 2643 if (!empty($records)) {
@@ -2293,24 +2667,26 @@
2293 2667 }
2294 2668 try {
2295 2669 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2296 2670 global $wpdb;
2297 - $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
2298 2671 $url_normalized = rtrim(trim($url), '/');
2299 2672
2300 2673 $records = get_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY);
2301 2674
2302 2675 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"
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"
2306 2678 );
2307 2679
2308 2680 // 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.
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).
2310 2685 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");
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 + }
2313 2689 return false;
2314 2690 }
2315 2691
2316 2692 set_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY, $records ?: [], METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL);
@@ -2386,9 +2762,9 @@
2386 2762 }
2387 2763
2388 2764 # WooCommerce product by slug
2389 2765 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));
2766 + $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2391 2767 $products = wc_get_products(array('name' => $product_slug, 'limit' => 1, 'status' => 'publish'));
2392 2768 if (!empty($products)) {
2393 2769 $post_id = $products[0]->get_id();
2394 2770 } else {
@@ -2415,9 +2791,9 @@
2415 2791 }
2416 2792
2417 2793 # Category
2418 2794 if (strpos($route, '/category/') !== false) {
2419 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2795 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2420 2796 $category = get_category_by_slug($category_slug);
2421 2797 if ($category) {
2422 2798 return !metasync_would_term_return_404($category->term_id, 'category', $route);
2423 2799 }
@@ -2424,9 +2800,9 @@
2424 2800 }
2425 2801
2426 2802 # WooCommerce product category
2427 2803 if (strpos($route, '/product-category/') !== false) {
2428 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2804 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2429 2805 $term = get_term_by('slug', $category_slug, 'product_cat');
2430 2806 if ($term && !is_wp_error($term)) {
2431 2807 return !metasync_would_term_return_404($term->term_id, 'product_cat', $route);
2432 2808 }
@@ -2468,9 +2844,9 @@
2468 2844
2469 2845 $removed = 0;
2470 2846 $marked_permanent = 0;
2471 2847 $thirty_days_ago = strtotime('-30 days');
2472 - $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);
2473 2849
2474 2850 foreach ($records as $record) {
2475 2851 $url = trim($record->url_pattern);
2476 2852 if (empty($url)) {
@@ -2491,16 +2867,10 @@
2491 2867 }
2492 2868 }
2493 2869 }
2494 2870
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 2871 } catch (Exception $e) {
2502 - 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
2503 2873 }
2504 2874 }
2505 2875
2506 2876 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
@@ -2505,8 +2875,24 @@
2505 2875
2506 2876 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
2507 2877
2508 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 +/**
2509 2895 * Check if a post/page would return 404 without making HTTP request
2510 2896 * Uses WordPress database checks for fast validation
2511 2897 *
2512 2898 * @param int $post_id WordPress post ID
@@ -2682,9 +3068,9 @@
2682 3068 }
2683 3069 }
2684 3070
2685 3071 // WP Cloud's warmer also tags its requests with a query marker.
2686 - if ( ! $is_infra && isset( $_GET['x-cache-engine'] ) ) {
3072 + if ( ! $is_infra && isset( $_GET['x-cache-engine'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only flag for render throttling
2687 3073 $is_infra = true;
2688 3074 }
2689 3075
2690 3076 /**