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
metasync / otto / otto_pixel.php

otto_pixel.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at otto/otto_pixel.php

3,127 lines 146.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * This handles the OTTO SSR
4 */
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit;
7 }
8
9 if (!defined('METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY')) {
10 define('METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY', 'metasync_otto_excluded_urls_manual');
11 }
12 if (!defined('METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL')) {
13 define('METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL', 60);
14 }
15
16 # uses the simple html dom library
17 use simplehtmldom\HtmlDocument;
18
19 # include the otto class file
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 }
43 require_once plugin_dir_path( __FILE__ ) . '/Otto_html_class.php';
44 require_once plugin_dir_path( __FILE__ ) . '/Otto_pixel_class.php';
45 require_once plugin_dir_path( __FILE__ ) . '/metasync-otto-seo-functions.php';
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';
49 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-render-strategy.php';
50 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-config.php';
51 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-detector.php';
52 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-statistics-database.php';
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
79 # OPTIMIZED: get the metasync options (cached in static class)
80 $metasync_options = Metasync_Otto_Config::get_options();
81
82 # OTTO SSR is always enabled by default
83 $otto_enabled = true;
84
85 # add tag to wp head
86 add_action('wp_head', function(){
87 # load globals
88 global $metasync_options, $otto_enabled;
89
90 # OTTO SSR is always enabled
91 $string_enabled = 'true';
92
93 # OPTIMIZED: check uuid set using cached config
94 if(!Metasync_Otto_Config::is_otto_enabled()){
95 return;
96 }
97
98 # Performance optimization: Add DNS prefetch and preconnect for OTTO API
99 # This improves connection speed by resolving DNS and establishing connections early
100 # Use endpoint manager to get the correct domain
101 $otto_domain = 'sa.searchatlas.com'; # default
102 if (class_exists('Metasync_Endpoint_Manager')) {
103 $otto_api_domain = Metasync_Endpoint_Manager::get_endpoint('OTTO_API_DOMAIN');
104 $parsed = wp_parse_url($otto_api_domain);
105 if (!empty($parsed['host'])) {
106 $otto_domain = $parsed['host'];
107 }
108 }
109 echo '<link rel="dns-prefetch" href="//' . esc_attr($otto_domain) . '">' . "\n";
110 echo '<link rel="preconnect" href="https://' . esc_attr($otto_domain) . '" crossorigin>' . "\n";
111
112 # adding the otto tag to pages
113 $plugin_version = defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown';
114 # OPTIMIZED: use cached uuid
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).'">';
116
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
119 echo $otto_tag;
120 }, 1); # Priority 1 to output early in head
121
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 /**
164 * Start end point to handle requests on page updates
165 * The Otto Crawler will call this end point once a page is updated
166 **/
167
168 # function to register the route
169 function metasync_otto_crawl_notify($request){
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
176 # get request data params
177 $data = $request->get_json_params();
178
179 # fields
180 $fields = ['domain', 'urls'];
181
182 # validate the json request
183 foreach ($fields as $key => $value) {
184
185 # if the value is empty stop tehre
186 if(empty($data[$value])){
187
188 # Handle the POST request
189 return new WP_REST_Response(array(
190 'success' => false,
191 'message' => 'Invalid Field : '. $value,
192 ), 400);
193 }
194
195 }
196
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 }
217
218 # Defer the expensive per-URL work (OTTO API fetch, post meta sync,
219 # per-URL cache purge) to background jobs. The webhook caller enforces a
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.
223 #
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.
228 $max_jobs_per_batch = defined('METASYNC_MAX_JOBS_PER_BATCH') ? METASYNC_MAX_JOBS_PER_BATCH : 25;
229 $now = time();
230 $routes_to_process = array();
231 $accepted = 0;
232 $deferred = 0;
233 $rejected = 0;
234 $scheduled_this_batch = 0;
235
236 foreach($data['urls'] AS $key => $url){
237 # prepare the route
238 $route = $data['domain'] . $url;
239
240 # validate the route
241 $route = rtrim($route, '/');
242
243 # Resolve redirect table: use final destination URL before excluded/404 checks and OTTO processing
244 $route = metasync_otto_resolve_redirect_to_final_url($route);
245
246 # OTTO has confirmed this URL is crawlable. Remove any auto-exclusion that was
247 # previously set (e.g. because url_to_postid() returned 0 for a custom post type
248 # and metasync_otto_is_url_available() incorrectly treated it as a 404).
249 # Manual exclusions (auto_excluded = 0) are left untouched.
250 metasync_otto_remove_auto_exclusion($route);
251
252 # Skip manually excluded URLs - don't queue OTTO processing for them
253 if (metasync_is_otto_url_excluded($route)) {
254 continue;
255 }
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
279 # Queue the per-URL processing for background execution. The handler
280 # (metasync_handle_otto_crawl_url_job) performs transient warming,
281 # SEO meta sync, and per-URL host cache purge.
282 # Offset each event by $key seconds to avoid wp_schedule_single_event()
283 # silently dropping duplicates when timestamp + hook + args collide.
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 }
296 }
297
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.
302 if (!empty($routes_to_process)) {
303 $batch_time = $now + count($data['urls']) + 5; // run after all per-URL jobs
304 metasync_otto_schedule_single_event($batch_time, 'metasync_process_otto_batch_cache_job', array($routes_to_process));
305 }
306
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());
310 }
311
312 # Return 200 immediately so the webhook caller does not time out, with an
313 # honest accounting of what happened to the batch.
314 return new WP_REST_Response(array(
315 'success' => true,
316 'message' => 'OTTO crawl notification received',
317 'accepted' => $accepted,
318 'deferred' => $deferred,
319 'rejected' => $rejected,
320 ), 200);
321 }
322
323 /**
324 * Background handler for a single OTTO crawl-notify URL.
325 *
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.
333 *
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.
339 *
340 * @param string $route Fully-qualified URL to process.
341 * @param int $retry_count Current retry attempt (0 = first run).
342 */
343 function metasync_handle_otto_crawl_url_job($route = '', $retry_count = 0) {
344 // WP-Cron events persist independently of plugin code. A stale or malformed
345 // event may therefore invoke this callback without its required route.
346 // Docblock type isn't enforced at runtime; a stale cron record can pass a non-string.
347 // @phpstan-ignore-next-line function.alreadyNarrowedType
348 if (!is_string($route) || $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
350 return;
351 }
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
375 try {
376 # Not connected: retrying cannot help, and there is nothing to purge.
377 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
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;
384 }
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
407 # Step 2: Write SEO post meta synchronously BEFORE the cache purge.
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);
419
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.
437 $otto_pixel = new Metasync_otto_pixel(false);
438 $otto_pixel->refresh_cache($route);
439 Metasync_Cache_Purge::purge_single_url($route);
440 Metasync_Cache_Purge::warm_urls(array($route));
441
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 }
447
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 }
473 }
474 }
475 }
476
477 /**
478 * Batch handler for the edge CDN purge.
479 *
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.).
484 *
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.
492 */
493 function metasync_handle_otto_batch_cache_job($routes = array(), $wait_count = 0) {
494 // WP-Cron events persist independently of plugin code. A stale or malformed
495 // event may therefore invoke this callback without its required route list.
496 // Docblock type isn't enforced at runtime; a stale cron record can pass a non-array.
497 // @phpstan-ignore-next-line function.alreadyNarrowedType
498 if (!is_array($routes) || empty($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
500 return;
501 }
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
537 try {
538 # Purge edge CDN caches (Cloudflare, Fastly, Akamai, Sucuri, Sevalla, etc.)
539 # Tag-based providers purge only the affected posts; full-flush providers fire once per batch.
540 Metasync_Edge_Cache_Purge::purge($ready);
541 } catch (Exception $e) {
542 metasync_record_failed_action('metasync_process_otto_batch_cache_job');
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
544 }
545 }
546
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;
562 }
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++;
577 } else {
578 # Cron still refusing: put it back at the end of the queue.
579 Metasync_Otto_Job_Status::enqueue_pending($route);
580 }
581 }
582
583 if ($scheduled > 0) {
584 metasync_otto_schedule_single_event($now + count($chunk) + 5, 'metasync_process_otto_batch_cache_job', array($chunk));
585 }
586
587 if (Metasync_Otto_Job_Status::pending_count() > 0) {
588 metasync_otto_schedule_single_event($now + 60, 'metasync_otto_pending_drainer', array());
589 }
590 }
591
592 // metasync_is_custom_or_lps_page() now lives in includes/metasync-helpers.php
593 // (loaded unconditionally before this file) so all SEO surfaces share one rule.
594
595 /**
596 * Detect an Elementor editor / preview request that OTTO must never process.
597 *
598 * Elementor renders its editing canvas in a front-end <iframe> — a logged-in
599 * GET request that is NOT is_admin(), so the admin/ajax/REST guards in
600 * metasync_start_otto() do not catch it. If OTTO output-buffers and rewrites
601 * that response (SimpleHtmlDom in Otto_html_class), the markup the editor's
602 * JavaScript expects is altered and the canvas stays stuck on "loading" —
603 * the editor never finishes initialising. The editor surface is for authoring
604 * only; it is never crawled, so there is nothing for OTTO to optimise there.
605 *
606 * Locally this is masked because the OTTO API is unreachable (API_ERROR ⇒ no
607 * suggestions ⇒ no rewrite); on a live site with real suggestions the rewrite
608 * happens and the editor breaks. See the "Elementor editor fails to load while
609 * the plugin is active" report.
610 *
611 * @return bool True when the current request is an Elementor editor/preview.
612 */
613 function metasync_is_elementor_editor_request() {
614 # The preview iframe loads the front end with ?elementor-preview=POST_ID.
615 # This is the request OTTO would otherwise buffer and corrupt.
616 if (isset($_GET['elementor-preview'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
617 return true;
618 }
619
620 # Elementor editor / app entry points carried as an action on the front end.
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
623 ) {
624 return true;
625 }
626
627 # Authoritative check when Elementor is loaded: its own preview-mode flag.
628 if (class_exists('\Elementor\Plugin')
629 && isset(\Elementor\Plugin::$instance->preview)
630 && is_object(\Elementor\Plugin::$instance->preview)
631 && method_exists(\Elementor\Plugin::$instance->preview, 'is_preview_mode')
632 && \Elementor\Plugin::$instance->preview->is_preview_mode()
633 ) {
634 return true;
635 }
636
637 return false;
638 }
639
640 function metasync_start_otto(){
641
642 # PERFORMANCE FIX: Cache is now enabled for speed
643 # Skip initial cache cleanup to preserve existing cache
644 # Cache files are valuable for performance - only clear on OTTO updates
645 # Periodic cache clearing can be configured in plugin settings if needed
646
647 # exclude AJAX requests and WooCommerce transactional pages from OTTO SSR
648 # SSR is now ENABLED for: single products, product categories, product tags
649 # SSR is SKIPPED for: cart, checkout
650 # Note: title/description filters (pre_get_document_title, wp_head meta desc)
651 # run on ALL pages regardless — they are hooked unconditionally in seo-functions.php
652
653 # ── WooCommerce-independent cart/checkout protection ──────────
654 # The is_cart()/is_checkout() guards below only recognize WooCommerce. Carts
655 # from other systems — e.g. the Point of Rental "Catalog" plugin on
656 # venturarental.com — are invisible to them, so OTTO would process those
657 # pages and let caching layers store them. A cached cart page corrupts the
658 # live cart (added items vanish because a stale page is served). The checks
659 # here do not depend on WooCommerce being active.
660
661 # Never run OTTO on non-GET requests. SSR exists for crawlers, which only
662 # issue GET; POST/PUT/etc. are form or cart submissions that must pass
663 # through untouched. (OTTO's own internal fetches use GET.)
664 if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
665 return;
666 }
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
684 # Never run OTTO on the Elementor editor/preview iframe. It is a logged-in
685 # front-end GET (not is_admin()), so it slips past the admin guard below;
686 # buffering and rewriting it breaks the editor canvas ("fails to load").
687 if (metasync_is_elementor_editor_request()) {
688 return;
689 }
690
691 # WP core file-editor self-check. When an admin saves a THEME file
692 # via the Plugin/Theme Editor, wp_edit_theme_plugin_file() fires an internal
693 # loopback GET to home_url('/') carrying wp_scrape_key/wp_scrape_nonce to
694 # detect a white-screen. That request is NOT is_admin(), so — exactly like
695 # the Elementor case above — it slips past the admin/AJAX/REST guards below.
696 # OTTO must not output-buffer or rewrite it: the SimpleHtmlDom pass (and any
697 # fatal thrown inside the ob_start() callback, e.g. under memory pressure)
698 # corrupts the scrape and surfaces as the misleading
699 # "preg_match(): Cannot use output buffering in output buffering display
700 # handlers" fatal. Skipping OTTO lets WP render the page normally so the
701 # scrape works. Returning here also avoids the HTTP-fallback path firing a
702 # second loopback for an already-internal self-request.
703 if (metasync_is_scrape_request()) {
704 return;
705 }
706
707 # Skip OTTO on cart/checkout-type paths regardless of the cart plugin, and
708 # signal caching plugins (WP Rocket/Kinsta/etc.) to never cache them — a
709 # cached cart/checkout page is precisely what corrupts cart state.
710 # Matched against the FIRST path segment (relative to the WP home path) so
711 # subdirectory installs (/shop/cart) still work, while unrelated content pages
712 # like /blog/cart or /features/checkout do NOT false-match.
713 # Drop the query string with explode() (no shared internal pointer like strtok),
714 # then lower-case and strip surrounding slashes.
715 $otto_req_path = explode('?', (string) wp_unslash($_SERVER['REQUEST_URI'] ?? ''), 2)[0];
716 $otto_req_path = strtolower(trim($otto_req_path, '/'));
717 # Strip the site's base path so first-segment matching works on subdir installs.
718 $otto_home_path = trim((string) wp_parse_url(home_url(), PHP_URL_PATH), '/');
719 if ($otto_home_path !== '' && strpos($otto_req_path, $otto_home_path . '/') === 0) {
720 $otto_req_path = substr($otto_req_path, strlen($otto_home_path) + 1);
721 }
722 $otto_first_segment = explode('/', $otto_req_path, 2)[0];
723 # Filterable so sites can add/remove transactional slugs without code changes.
724 $otto_cart_segments = apply_filters('metasync_otto_cart_paths', array(
725 'cart', 'checkout', 'basket', 'request-a-quote', 'quote-request',
726 ));
727 $otto_cart_segments = array_map('strtolower', (array) $otto_cart_segments);
728 if ($otto_first_segment !== '' && in_array($otto_first_segment, $otto_cart_segments, true)) {
729 if (!defined('DONOTCACHEPAGE')) {
730 define('DONOTCACHEPAGE', true);
731 }
732 return;
733 }
734 # ──────────────────────────────────────────────────────────────────────
735
736 if (
737 # disable ajax calls
738 isset($_GET['ucfrontajaxaction']) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
739 # OTTO Preview mode - skip OTTO when previewing original content
740 (isset($_GET['otto_preview']) && $_GET['otto_preview'] === '1') || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
741 # WooCommerce shop archive page only (products and categories now use SSR)
742 //(function_exists('is_shop') && is_shop()) ||
743 # Cart page
744 (function_exists('is_cart') && is_cart()) ||
745 # Checkout page
746 (function_exists('is_checkout') && is_checkout()) ||
747 # My Account page
748 //(function_exists('is_account_page') && is_account_page()) ||
749 # Standard WordPress AJAX
750 (function_exists('wp_doing_ajax') && wp_doing_ajax()) ||
751 # check by constant
752 (defined('DOING_AJAX') && DOING_AJAX) ||
753 # WooCommerce AJAX endpoint (e.g., ?wc-ajax=update_cart)
754 (isset($_REQUEST['wc-ajax']) && !empty($_REQUEST['wc-ajax'])) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
755 # AJAX requests via X-Requested-With header
756 (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') ||
757 # Gravity Forms submission detection - skip OTTO to allow form processing
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
761 )) ||
762 # Gravity Forms AJAX submission
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
765 )) ||
766 # Gravity Forms file upload
767 (isset($_POST['gform_uploaded_files'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
768 # Any Gravity Forms POST parameter
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
770 # Formidable Forms AJAX submission detection - skip OTTO to allow form processing
771 (isset($_POST['action']) && $_POST['action'] === 'frm_entries_create') || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
772 # Formidable Forms POST parameters
773 (isset($_POST['form_id']) && !empty($_POST['form_id'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
774 # Formidable Forms action parameter
775 (isset($_POST['frm_action']) && !empty($_POST['frm_action'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
776 # Formidable Forms item_key (used in form submissions)
777 (isset($_POST['item_key']) && !empty($_POST['item_key'])) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
778 ) {
779 return;
780 }
781
782 # fetch globals
783 global $metasync_options, $otto_enabled;
784
785 # OPTIMIZED: check for the disable otto for logged in users option using cached config
786 if(Metasync_Otto_Config::is_disabled_for_loggedin()){
787
788 # get user
789 $current_user = wp_get_current_user();
790
791 # check if user is logged in
792 if( !empty($current_user->ID)){
793
794 return;
795 }
796 }
797
798 # check if current URL is manually excluded from OTTO
799 # NOTE: auto-exclusions (false-positive 404 detections) are intentionally NOT checked
800 # here — they must not block OTTO rendering. Use metasync_is_otto_url_excluded() only
801 # in the webhook handler where we gate SEO meta writes.
802 $request_uri = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
803 $current_url = home_url(strtok($request_uri, '?') ?: $request_uri);
804 if (metasync_is_otto_url_manually_excluded($current_url)) {
805 return;
806 }
807
808 # Skip OTTO for XML endpoints (sitemaps, RSS-as-xml, etc.). OTTO has no
809 # suggestions for machine-readable XML, and the upstream API returns
810 # API_ERROR for these URLs — which then stamps misleading
811 # X-MetaSync-OTTO-Cache: API_ERROR / X-MetaSync-OTTO-Method: NONE headers
812 # onto otherwise-healthy sitemap responses and causes false-alarm bug
813 # reports from customers.
814 $request_path = strtok($request_uri, '?');
815 if ($request_path && preg_match('#\.xml$#i', $request_path)) {
816 return;
817 }
818
819 # Machine-readable core endpoints. Using WordPress's own conditionals rather
820 # than matching the path: they are exact, and a path pattern here would also
821 # match ordinary content whose slug merely starts with the same characters
822 # (e.g. /robots.txt-explained/, /favicon.ico-vs-svg/).
823 # is_favicon() is WP 5.4+; this plugin supports 5.2.
824 if (is_robots() || (function_exists('is_favicon') && is_favicon())) {
825 return;
826 }
827
828 # Protocol paths and static assets that are never OTTO-able content.
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')
835 && Metasync_Otto_Render_Strategy::is_non_content_path($request_path)) {
836 return;
837 }
838
839 # BOT DETECTION: Always detect bots so crawl data reaches the SA backend.
840 # Real bots don't execute JS, so otto-tracker.js never fires for them.
841 # push_crawl_log_to_sa() fires a non-blocking wp_remote_post here instead.
842 $bot_detector = Metasync_Otto_Bot_Detector::get_instance();
843 $detection = $bot_detector->detect();
844 if ( $detection['is_bot'] ) {
845 $bot_detector->push_crawl_log_to_sa( $detection, $current_url );
846 }
847
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.
883 if ( $detection['is_bot']
884 && ! Metasync_Otto_Bot_Detector::is_unthrottled_category( $detection['bot_type'] )
885 && ! metasync_otto_is_unthrottled_infrastructure_agent( $detection ) ) {
886 $normalized_url = strtok( $current_url, '?' );
887 if ( $normalized_url === false ) {
888 $normalized_url = $current_url;
889 }
890 // Lowercase + length cap so attacker-controlled bot_name variations
891 // (EvilBota, EvilBotB, EvilBotc, ...) collapse to a bounded key space
892 // and cannot flood wp_options with unique transient rows.
893 $bot_name = substr( strtolower( $detection['bot_name'] ?? 'unknown' ), 0, 32 );
894 $render_throttle_key = 'metasync_otto_rendered_' . md5( $normalized_url . '|' . $bot_name );
895 if ( get_transient( $render_throttle_key ) ) {
896 // Prevent page caches (WP Rocket, W3TC, etc.) from saving the
897 // un-OTTO'd response and serving it to subsequent human visitors.
898 if ( ! defined( 'DONOTCACHEPAGE' ) ) {
899 define( 'DONOTCACHEPAGE', true );
900 }
901 return;
902 }
903 set_transient( $render_throttle_key, 1, 5 * MINUTE_IN_SECONDS );
904 }
905
906 # Skip OTTO on 404s.
907 #
908 # OTTO suggestions are keyed to real, crawled pages. Asking the API about a URL
909 # that returns 404 spends a full API_TIMEOUT of PHP-FPM worker time on a
910 # guaranteed-empty answer. Not theoretical: in one production sample, 21 of 54
911 # crawler requests were 404s on URLs that never existed on the site, each
912 # costing ~2s of a worker.
913 #
914 # Deliberately placed AFTER bot detection so push_crawl_log_to_sa() still
915 # reports the crawl to SearchAtlas — that telemetry is how the crawler side
916 # can learn to stop requesting these URLs. Only the expensive suggestions
917 # lookup is skipped.
918 #
919 # Filterable because "should a 404 page get OTTO treatment" is arguably a site
920 # decision, even though there is no known case where it should.
921 if (is_404() && apply_filters('metasync_otto_skip_on_404', true)) {
922 return;
923 }
924
925 # Optionally skip OTTO processing for bot traffic (when the setting is enabled)
926 if ($bot_detector->should_skip_otto()) {
927 // Log the bot locally and count the saved API call
928 $bot_detector->log_detection($detection);
929 $bot_stats_db = Metasync_Otto_Bot_Statistics_Database::get_instance();
930 $bot_stats_db->increment_api_calls_saved();
931
932 // Skip OTTO processing for this bot
933 return;
934 }
935
936 # OPTIMIZED: Check if Otto should be disabled for WP Rocket compatibility
937 if (defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket')) {
938 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
939
940 if ($wp_rocket_compat_mode === 'disable_otto') {
941 return; # Exit early, Otto is disabled when WP Rocket is active
942 }
943 }
944
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.
949 # /page/N/ = paginated blog/archive pages — OTTO's buffer/HTTP render causes
950 # module numbering mismatch between page 1 (with TB template) and page N
951 # (without TB template), breaking Divi's JS pagination selector matching.
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
962 return;
963 }
964
965 # Skip OTTO on search-results pages. A search request carries its
966 # meaning entirely in the query string (e.g. /?s=term, or FiboSearch's
967 # /?s=term&post_type=product&dgwt_wcas=1). get_route() intentionally strips
968 # the query string to build a canonical, cache-key-stable route — which
969 # collapses a query-only search URL to the site root ('/'). OTTO then finds
970 # the HOME page's suggestions for that route and, on the HTTP render path,
971 # serves the home page in place of the search results (the address bar keeps
972 # the search URL — it is a silent content swap, not a redirect). This only
973 # surfaces for logged-in / uncached visitors, since page caches serve the
974 # correct pre-rendered search page to everyone else. Search-results pages
975 # have no per-canonical-URL OTTO suggestions of their own, so OTTO must never
976 # run here. The raw $_GET['s'] check is a defensive fallback for setups where
977 # the main query is altered before this point.
978 if (is_search() || !empty($_GET['s'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
979 return;
980 }
981
982 # Handle cache plugin compatibility early - before any caching happens
983 metasync_otto_handle_cache_compatibility();
984
985 # check if OTTO is disabled for this specific page/post
986 # Gate on is_singular() for the same reason the custom/LPS check below does:
987 # on an archive, search or term query get_the_ID() returns the first post of
988 # the loop — WP::register_globals() seeds $GLOBALS['post'] with it — not the
989 # page being rendered. Without the gate, one excluded or per-page-disabled
990 # post silently takes OTTO off every archive that happens to list it first.
991 $post_id = is_singular() ? ( get_queried_object_id() ?: get_the_ID() ) : 0;
992 if ($post_id && class_exists('Metasync_Otto_Frontend_Toolbar')) {
993 if (Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id)) {
994 return;
995 }
996 }
997
998 # Skip OTTO on MetaSync custom HTML / LPS-imported pages — they ship their
999 # own complete, self-contained SEO and OTTO must not inject or overwrite it
1000 # with a different/older project's SEO. Resolve the queried object
1001 # id (with get_the_ID() fallback) so the static-front-page case — an LPS home
1002 # set as the WP front page, where is_page() is false — is still detected.
1003 # Applying the skip here, upstream of the single render_route_html() entry,
1004 # covers all three render paths (Rocket buffer, output buffer, HTTP fallback).
1005 # Only singular views (posts/pages, incl. a static front page) can be a
1006 # custom/LPS page; gate on is_singular() so an archive/search/term query
1007 # can never have its object id mistaken for a custom page's post id.
1008 $custom_page_id = is_singular() ? ( get_queried_object_id() ?: get_the_ID() ) : 0;
1009 if (metasync_is_custom_or_lps_page($custom_page_id)) {
1010 if (!headers_sent() && Metasync_Otto_Render_Strategy::diagnostics_enabled()) {
1011 header('X-MetaSync-OTTO-Method: EXCLUDED');
1012 }
1013 return;
1014 }
1015
1016 # check if we are having an otto request
1017 #
1018 # Recognise our own internal fetch by header/User-Agent as well as by the
1019 # query parameter. A redirect that rebuilds the URL strips the parameter,
1020 # and without this the redirected request re-enters OTTO and fires another
1021 # internal fetch - an unbounded self-request loop.
1022 # method_exists() is redundant to static analysis - this MR adds the method,
1023 # so PHPStan proves the call always true. It is kept for the upgrade window:
1024 # during a plugin update an opcache can still hold the previous
1025 # Metasync_Otto_Render_Strategy, where class_exists() passes but the method
1026 # is absent, and an unguarded static call would fatal the page.
1027 # @phpstan-ignore-next-line function.alreadyNarrowedType
1028 $otto_has_fetch_detector = class_exists('Metasync_Otto_Render_Strategy') && method_exists('Metasync_Otto_Render_Strategy', 'is_internal_fetch');
1029
1030 $otto_is_internal_fetch = $otto_has_fetch_detector
1031 ? Metasync_Otto_Render_Strategy::is_internal_fetch()
1032 : !empty($_GET['is_otto_page_fetch']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only loopback marker, request-scoped filters only
1033
1034 if($otto_is_internal_fetch){
1035
1036 # Block SEO plugins NOW for this internal fetch request
1037 # metasync_otto_block_seo_plugins();
1038 # $_SERVER['REQUEST_URI'] = remove_query_arg('is_otto_page_fetch', $_SERVER['REQUEST_URI']);
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
1041
1042 # Block SEO plugins conditionally based on what Otto has
1043 if ($block_title || $block_description) {
1044 metasync_otto_block_seo_plugins($block_title, $block_description);
1045 }
1046
1047 # Remove ALL Otto parameters from REQUEST_URI to prevent them from appearing in pagination, etc.
1048 $request_uri_raw = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
1049 $_SERVER['REQUEST_URI'] = remove_query_arg(
1050 ['is_otto_page_fetch', 'otto_block_title', 'otto_block_desc'],
1051 $request_uri_raw
1052 );
1053
1054 # Also remove from $_GET to prevent WordPress from using them
1055 unset($_GET['is_otto_page_fetch']);
1056 unset($_GET['otto_block_title']);
1057 unset($_GET['otto_block_desc']);
1058 return;
1059 }
1060
1061 # to avoid unnecessary processese
1062 # OPTIMIZED: check that otto is configured
1063 # And the UUID is properly set before running OTT
1064
1065 # check that we have the option
1066 if(!Metasync_Otto_Config::is_otto_enabled()){
1067 return;
1068 }
1069
1070 # check that otto is enabled
1071 if(!$otto_enabled){
1072 return;
1073 }
1074
1075 # get the otto uuid
1076 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
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
1089 # start the class
1090 $otto = new Metasync_otto_pixel($otto_uuid);
1091
1092 # call render
1093 $otto->render_route_html();
1094 }
1095
1096 /**
1097 * Handle cache plugin compatibility with Otto
1098 * Controls DONOTCACHEPAGE constant based on active plugins and configuration
1099 * This function is called early in the WordPress lifecycle
1100 */
1101 function metasync_otto_handle_cache_compatibility() {
1102 # Detect active plugins. SG Optimizer detection now lives in
1103 # metasync_otto_disable_sg_page_cache(), which is invoked later
1104 # once OTTO confirms suggestions for the URL.
1105 $brizy_active = class_exists('Brizy_Editor') || defined('BRIZY_VERSION');
1106 $wp_rocket_active = defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket');
1107
1108 # OPTIMIZED: Get configuration option using cached config
1109 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
1110
1111 # Check for Brizy posts in database
1112 global $wpdb;
1113 $has_brizy_posts = false;
1114
1115 if ($brizy_active) {
1116 # OPTIMIZED: Check cache first (1-hour TTL) to avoid querying on every page load
1117 $cached = get_transient('metasync_has_brizy_posts');
1118 if ($cached !== false) {
1119 $has_brizy_posts = ($cached === 'yes');
1120 } else {
1121 # Query database only if cache missed
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
1123 "SELECT COUNT(*) FROM {$wpdb->postmeta}
1124 WHERE meta_key = 'brizy_post_uid'
1125 AND meta_value != ''
1126 LIMIT 1"
1127 );
1128
1129 # Cache result for 1 hour
1130 $result = !empty($has_brizy_posts) ? 'yes' : 'no';
1131 set_transient('metasync_has_brizy_posts', $result, HOUR_IN_SECONDS);
1132 }
1133 }
1134
1135 # Determine if DONOTCACHEPAGE should be set
1136 $should_set_donotcachepage = false;
1137
1138 # Case 1: Brizy is active with posts - always needed. Brizy pages are
1139 # dynamically rendered regardless of OTTO, so caching must be disabled
1140 # whenever Brizy posts exist.
1141 if ($brizy_active && !empty($has_brizy_posts)) {
1142 $should_set_donotcachepage = true;
1143 }
1144
1145 # Case 2: User explicitly disabled Otto for WP Rocket compatibility
1146 elseif ($wp_rocket_active && $wp_rocket_compat_mode === 'disable_otto') {
1147 $should_set_donotcachepage = true;
1148 return; # Exit early, Otto won't run
1149 }
1150
1151 # Case 3: WP Rocket active with auto/buffer mode - DON'T set DONOTCACHEPAGE
1152 # This allows WP Rocket optimizations to continue working
1153
1154 # NOTE: SiteGround SG Optimizer cache bypass is intentionally NOT
1155 # handled here. Emitting no-cache headers / disabling SG cache on this hook
1156 # fired on EVERY front-end page — including pages OTTO never modifies —
1157 # forcing SG to bypass its page cache site-wide and spiking CPU. The SG
1158 # bypass is now deferred to metasync_otto_disable_sg_page_cache(), called
1159 # from render_route_html() only after OTTO confirms it has suggestions for
1160 # the current URL.
1161
1162 # Only set DONOTCACHEPAGE if needed
1163 if ($should_set_donotcachepage && !defined('DONOTCACHEPAGE')) {
1164 define('DONOTCACHEPAGE', true);
1165 }
1166 }
1167
1168 /**
1169 * Disable SiteGround SG Optimizer page caching for the CURRENT request only.
1170 *
1171 * Called from Metasync_otto_pixel::render_route_html() once OTTO has confirmed
1172 * it has suggestions to apply to the requested URL. Scoping the no-cache
1173 * override to pages OTTO actually modifies — rather than emitting it on every
1174 * front-end page via the unconditional `wp` hook — keeps SG's page cache
1175 * working site-wide and avoids the CPU spike reported in.
1176 *
1177 * Only meaningful on SiteGround sites without WP Rocket; a no-op otherwise.
1178 */
1179 function metasync_otto_disable_sg_page_cache() {
1180 # Only relevant when SG Optimizer is active and WP Rocket is not present.
1181 if (defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket')) {
1182 return;
1183 }
1184 if (!is_plugin_active('sg-cachepress/sg-cachepress.php')) {
1185 return;
1186 }
1187
1188 if (!defined('DONOTCACHEPAGE')) {
1189 define('DONOTCACHEPAGE', true);
1190 }
1191 if (!defined('SG_CachePress_SUPERCACHER')) {
1192 define('SG_CachePress_SUPERCACHER', false);
1193 }
1194
1195 add_filter('sgo_html_cache_disable', '__return_true', 999);
1196 add_filter('sgo_css_combine_exclude', '__return_true', 999);
1197 add_filter('sgo_js_combine_exclude', '__return_true', 999);
1198 add_filter('sgo_cache_this_page', '__return_false', 999);
1199
1200 if (!headers_sent()) {
1201 header('Cache-Control: no-cache, must-revalidate, max-age=0');
1202 header('X-Accel-Expires: 0');
1203 }
1204 }
1205
1206 /**
1207 * How long a response that OTTO could not optimize may be cached, in seconds.
1208 *
1209 * Deliberately short rather than zero — see
1210 * metasync_otto_limit_response_cache() for why.
1211 */
1212 if (!defined('METASYNC_OTTO_FALLBACK_CACHE_TTL')) {
1213 define('METASYNC_OTTO_FALLBACK_CACHE_TTL', 60);
1214 }
1215
1216 /**
1217 * Cap how long the current response may be cached, because OTTO could not apply
1218 * its suggestions to it.
1219 *
1220 * OTTO fails open: when suggestions are unavailable, or when the HTML rewrite
1221 * fails, the site's own un-optimized markup is served instead. That markup is
1222 * valid — the harm is that a caching layer then stores it and serves it to
1223 * everyone for the rest of its TTL (a host edge cache with s-maxage=86400 pins
1224 * it for a day), which surfaces to customers as the OTTO title "reverting".
1225 *
1226 * A short TTL is used rather than `no-store`, which is the obvious choice but the
1227 * wrong one. Some failures look transient and are not: a page that always breaks
1228 * the rewriter, or a host that cannot fetch its own pages, fails identically on
1229 * every request. `no-store` would make those permanently uncacheable, sending
1230 * 100% of their traffic to the origin forever in exchange for markup that will
1231 * never improve. A one-minute ceiling fixes the reported bug just as completely —
1232 * a day-long pin becomes a minute — while capping the cost of being wrong about
1233 * which failures recover.
1234 *
1235 * Different layers read different signals, so all of them are emitted:
1236 *
1237 * - Cache-Control CDNs, host edge caches, browsers. `max-age=0` keeps
1238 * browsers revalidating while `s-maxage` lets shared
1239 * caches absorb a burst for the ceiling's duration.
1240 * - X-Accel-Expires Nginx's own directive. Evaluated with higher
1241 * precedence than Cache-Control, and not covered by
1242 * the `fastcgi_ignore_headers Cache-Control Expires`
1243 * recipe common on managed hosts — so it can survive
1244 * a config that discards Cache-Control. Nginx strips
1245 * X-Accel-* before the response reaches the client,
1246 * so there is no visitor-facing side effect.
1247 * - Surrogate-Control Varnish and Fastly. Note that on stock Varnish the
1248 * mere presence of this header suppresses its
1249 * Cache-Control check, so it must carry a real
1250 * lifetime rather than be left at some other value.
1251 * - DONOTCACHEPAGE WordPress-level page caches (WP Rocket, W3TC,
1252 * LiteSpeed, SG Optimizer). These run inside PHP and
1253 * never see response headers. They have no notion of a
1254 * short TTL — it is cache or do not — so they skip
1255 * entirely. That is safe here because callers only
1256 * invoke this for failures expected to resolve; a
1257 * permanent one (an oversized page, a host that cannot
1258 * reach itself) is left fully cacheable by the caller
1259 * and never reaches this function.
1260 *
1261 * Cache-tagging headers (Surrogate-Key, Cache-Tag, Edge-Cache-Tag) are
1262 * deliberately left in place. They are purge-targeting labels, not cacheability
1263 * directives — no CDN stores a response *because* a tag is present. On a layer
1264 * that stores this response anyway, those tags are the only handle a later purge
1265 * has on it, so removing them would discard the recovery path exactly where it
1266 * is needed most.
1267 *
1268 * Some layers may honour none of this; a managed host that overrides origin
1269 * cache headers will cache the response regardless. This bounds the damage where
1270 * it can and is inert where it cannot.
1271 *
1272 * @param string $reason Short machine-readable reason, surfaced as a response
1273 * header for debugging (e.g. 'API_ERROR', 'RENDER_FAILED').
1274 */
1275 /**
1276 * Build the response headers that cap how long an un-optimized response may be
1277 * cached.
1278 *
1279 * Thin wrapper over Metasync_Otto_Render_Strategy::fallback_cache_headers(), which
1280 * owns the header set so it can be unit-tested (this file cannot be loaded in a
1281 * test context). Returns an empty array if the strategy class is somehow absent,
1282 * so a caller emits nothing rather than an untested divergent copy.
1283 *
1284 * @param int $ttl Cache ceiling in seconds.
1285 * @param string $reason Optional machine-readable reason for the debug header.
1286 * @return array Header name => value.
1287 */
1288 function metasync_otto_fallback_cache_headers($ttl, $reason = '') {
1289 if (!class_exists('Metasync_Otto_Render_Strategy')) {
1290 return [];
1291 }
1292
1293 return Metasync_Otto_Render_Strategy::fallback_cache_headers_for_response($ttl, $reason);
1294 }
1295
1296 function metasync_otto_limit_response_cache($reason = '') {
1297 # This function must only ever make the response LESS cacheable. header()
1298 # replaces by default and the capped set carries X-Accel-Expires, which nginx
1299 # evaluates ahead of Cache-Control — so emitting it unconditionally could
1300 # override an upstream no-store and grant edge caching that nothing had
1301 # granted. The strategy reconciles existing cache directives before emitting
1302 # a capped set.
1303
1304 $strategy_available = class_exists('Metasync_Otto_Render_Strategy');
1305
1306 # 1. Logged-in users. OTTO renders for them unless the "disable on logged in"
1307 # setting is switched on, which is off by default — so this is the common
1308 # case, not an edge case. Their page may carry session-specific or
1309 # membership-gated content, and capping at 60 seconds would still let a
1310 # shared cache store and re-serve it to somebody else.
1311 if (function_exists('is_user_logged_in') && is_user_logged_in()) {
1312 if (!headers_sent() && $strategy_available) {
1313 foreach (Metasync_Otto_Render_Strategy::no_store_headers($reason) as $name => $value) {
1314 header($name . ': ' . $value);
1315 }
1316 }
1317 if (!defined('DONOTCACHEPAGE')) {
1318 define('DONOTCACHEPAGE', true);
1319 }
1320 return;
1321 }
1322
1323 # 2. Something upstream already restricted this response. Leave its headers
1324 # alone — a page deliberately marked no-store must not be handed a 60
1325 # second shared-cache lifetime by us.
1326 # The strategy turns an existing no-store into a consistent no-store set and
1327 # uses the strictest known existing lifetime for every cache layer.
1328 $ttl = (int) METASYNC_OTTO_FALLBACK_CACHE_TTL;
1329
1330 if (!headers_sent()) {
1331 foreach (metasync_otto_fallback_cache_headers($ttl, $reason) as $name => $value) {
1332 header($name . ': ' . $value);
1333 }
1334 }
1335
1336 # Reaches page-cache plugins, which do not see the headers above.
1337 if (!defined('DONOTCACHEPAGE')) {
1338 define('DONOTCACHEPAGE', true);
1339 }
1340 }
1341
1342 /**
1343 * Report an OTTO render failure to the local error log and to Sentry.
1344 *
1345 * These failures were invisible: the render path swallowed them and returned the
1346 * original HTML, so nobody knew how often they fired. Reporting them is what makes
1347 * a persistently-failing URL discoverable instead of silently costing origin work.
1348 *
1349 * Two constraints shape this:
1350 *
1351 * 1. It runs on the visitor render path, so it must not add latency. The Sentry
1352 * report is therefore queued here and sent by
1353 * metasync_otto_flush_render_failure_reports() after the response has been
1354 * handed to the visitor, so the cost is invisible rather than merely small.
1355 *
1356 * Two alternatives were tried and rejected. Sending inline with a blocking
1357 * transport — cURL with a 5s connect and 5s read timeout — could add ten
1358 * seconds to a page load, precisely when the request is already degraded.
1359 * Sending inline non-blocking is far cheaper but still not free: opening the
1360 * connection and writing the body costs the visitor something.
1361 *
1362 * A WP-Cron single event was the other obvious option and is worse than both:
1363 * every scheduled event is stored in the `cron` option, which is autoloaded on
1364 * every request site-wide, and WP's own 10-minute duplicate suppression keys on
1365 * the serialized args — which carry per-request byte counts here, so it would
1366 * never engage. A site with many failing URLs would grow that option
1367 * unboundedly and pay for it on every page load, which is the cost the deferral
1368 * was meant to avoid in the first place.
1369 *
1370 * The local log stays synchronous. It is cheaper than Sentry but not free —
1371 * Metasync_Error_Logger::log() rewrites a summary option on each call — which is
1372 * the second reason for the throttle below.
1373 *
1374 * 2. A failing page can be requested thousands of times an hour, so reports are
1375 * throttled per reason+URL.
1376 *
1377 * @param string $reason Machine-readable failure reason (e.g. 'RENDER_EXCEPTION').
1378 * @param string $message Human-readable description.
1379 * @param array $context Extra key/value context.
1380 * @param string $level Sentry level: 'error' or 'warning'.
1381 */
1382 function metasync_otto_report_render_failure($reason, $message, $context = [], $level = 'error') {
1383 $url = '';
1384 if (!empty($_SERVER['HTTP_HOST']) && !empty($_SERVER['REQUEST_URI'])) {
1385 $url = (is_ssl() ? 'https://' : 'http://')
1386 . $_SERVER['HTTP_HOST']
1387 . strtok(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), '?');
1388 }
1389
1390 # Throttle: at most one report per reason+URL per 5 minutes. Requests without a
1391 # resolvable URL (CLI, odd SAPIs) collapse onto one key, which is intended —
1392 # they cannot be attributed to a page anyway.
1393 $throttle_key = 'metasync_otto_fail_' . md5($reason . '|' . $url);
1394 if (get_transient($throttle_key)) {
1395 return;
1396 }
1397 set_transient($throttle_key, 1, 5 * MINUTE_IN_SECONDS);
1398
1399 $context = array_merge([
1400 'reason' => $reason,
1401 'url' => $url,
1402 ], $context);
1403
1404 # Local structured log. Stays on the customer's site, so it is useful to
1405 # support but not visible to us — hence the Sentry dispatch below.
1406 if (class_exists('Metasync_Error_Logger')) {
1407 Metasync_Error_Logger::log(
1408 Metasync_Error_Logger::CATEGORY_OTTO_RENDER,
1409 $level === 'warning'
1410 ? Metasync_Error_Logger::SEVERITY_WARNING
1411 : Metasync_Error_Logger::SEVERITY_ERROR,
1412 $message,
1413 $context
1414 );
1415 }
1416
1417 # Sentry. Queued rather than sent here: even a non-blocking dispatch costs the
1418 # visitor something (opening the connection and writing the body), and this runs
1419 # on a request that is already degraded. The queue is flushed after the response
1420 # has been handed to the visitor — see metasync_otto_flush_render_failure_reports().
1421 if (!function_exists('metasync_sentry_capture_message_nonblocking')) {
1422 return;
1423 }
1424
1425 $GLOBALS['metasync_otto_pending_failure_reports'][] = [
1426 'message' => $message,
1427 'level' => $level,
1428 'context' => $context,
1429 ];
1430
1431 if (empty($GLOBALS['metasync_otto_failure_flush_hooked'])) {
1432 $GLOBALS['metasync_otto_failure_flush_hooked'] = true;
1433 # PHP_INT_MAX so this runs after everything else registered on shutdown —
1434 # WordPress flushes its own output buffers and closes the object cache at the
1435 # default priority, and this handler ends output for the request.
1436 add_action('shutdown', 'metasync_otto_flush_render_failure_reports', PHP_INT_MAX);
1437 }
1438 }
1439
1440 /**
1441 * Send any queued render-failure reports after the response has been delivered.
1442 *
1443 * fastcgi_finish_request() (PHP-FPM) and litespeed_finish_request() (LiteSpeed)
1444 * flush the response and close the connection to the visitor while leaving PHP
1445 * running. Calling one of them here means the page is already on its way before
1446 * any telemetry work starts, so the reporting cost is invisible to the visitor
1447 * rather than merely small.
1448 *
1449 * Neither exists under mod_php or the CLI SAPI. There the reports are still sent
1450 * at the very end of the request, after all page output has been generated and
1451 * flushed — the same ordering, just without the connection being closed first.
1452 *
1453 * The dispatch itself stays non-blocking. Now that the visitor is served, the only
1454 * remaining cost is PHP worker occupancy, and a blocking transport with a 5s
1455 * connect and 5s read timeout could tie up a worker for ten seconds per report on
1456 * a site that is failing often.
1457 */
1458 function metasync_otto_flush_render_failure_reports() {
1459 $reports = $GLOBALS['metasync_otto_pending_failure_reports'] ?? [];
1460 $GLOBALS['metasync_otto_pending_failure_reports'] = [];
1461
1462 if (empty($reports)) {
1463 return;
1464 }
1465
1466 if (function_exists('fastcgi_finish_request')) {
1467 fastcgi_finish_request();
1468 } elseif (function_exists('litespeed_finish_request')) {
1469 litespeed_finish_request();
1470 }
1471
1472 if (!function_exists('metasync_sentry_capture_message_nonblocking')) {
1473 return;
1474 }
1475
1476 foreach ($reports as $report) {
1477 try {
1478 metasync_sentry_capture_message_nonblocking(
1479 $report['message'],
1480 $report['level'],
1481 $report['context']
1482 );
1483 } catch (Exception $e) {
1484 # Telemetry must never be able to break a request.
1485 } catch (Error $e) {
1486 # Same.
1487 }
1488 }
1489 }
1490
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 /**
1513 * Block SEO plugins conditionally based on what Otto is providing
1514 * Only blocks title if Otto has title, only blocks description if Otto has description
1515 * This prevents duplicate SEO tags while allowing fallback to SEO plugins when Otto has no data.
1516 * Supports Yoast SEO, Rank Math, and AIOSEO (free + pro).
1517 *
1518 * @param bool $block_title Whether to block title tags.
1519 * @param bool $block_description Whether to block description tags.
1520 * @param array $description_tags Optional. Granular list of OTTO-provided description tags
1521 * (e.g. ['meta[name=description]', 'meta[property=og:description]']).
1522 * When provided, only matching AIOSEO tags are suppressed.
1523 * When empty, all AIOSEO description tags are suppressed (legacy behavior).
1524 */
1525 function metasync_otto_block_seo_plugins($block_title = false, $block_description = false, $description_tags = []) {
1526 # Disable Yoast SEO (free and premium)
1527 if (is_plugin_active('wordpress-seo/wp-seo.php') ||
1528 is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {
1529
1530 # TITLE: Never block Yoast's title output during SSR fetch.
1531 # Yoast removes WordPress's native _wp_render_title_tag action and is the sole
1532 # renderer of the <title> tag. Returning false/empty from wpseo_title or removing
1533 # Title_Presenter leaves the page with NO <title> tag at all — OTTO's buffer
1534 # post-processing then has nothing to replace, producing a missing title.
1535 # Instead, let Yoast render its own title; OTTO's replace_title() will overwrite
1536 # it in the final HTML buffer. deduplicate_title_tags() cleans up any duplicates.
1537
1538 # Block description only if Otto has description
1539 if ($block_description) {
1540 add_filter('wpseo_metadesc', '__return_false', 999);
1541 add_filter('wpseo_meta_description', '__return_false', 999);
1542 add_filter('wpseo_metakeywords', '__return_false', 999);
1543 }
1544
1545 # Block Yoast's modern presenters — description only, never title
1546 add_filter('wpseo_frontend_presenters', function($presenters) use ($block_description) {
1547 if (!is_array($presenters)) return $presenters;
1548
1549 $presenters_to_remove = [];
1550
1551
1552 # Remove description presenters only when OTTO has a description
1553 if ($block_description) {
1554 # The plain meta description belongs to the SEO title/description
1555 # feature, which has its own setting.
1556 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Meta_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 }
1565 }
1566
1567 foreach ($presenters as $key => $presenter) {
1568 // Safely get class name, suppressing autoload errors
1569 // This prevents warnings when Composer autoloader tries to load deprecated Yoast files
1570 $class_name = is_object($presenter) ? @get_class($presenter) : '';
1571
1572 if (!empty($class_name) && in_array($class_name, $presenters_to_remove)) {
1573 unset($presenters[$key]);
1574 }
1575 }
1576 return $presenters;
1577 }, 999);
1578 }
1579
1580 # Disable Rank Math
1581 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
1582 is_plugin_active('seo-by-rankmath/rank-math.php')) {
1583
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.
1589
1590 if ($block_description) {
1591 add_filter('rank_math/frontend/description', '__return_false', 999);
1592 add_filter('rank_math/frontend/show_keywords', '__return_false', 999);
1593 }
1594 }
1595
1596 # Disable AIOSEO (free and pro)
1597 if (is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
1598 is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
1599
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.
1603 add_filter('aioseo_title', '__return_empty_string', 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 }
1615 }
1616
1617 if ($block_description) {
1618 # Use granular tag list when available to only block what OTTO provides
1619 $tags = !empty($description_tags) ? $description_tags : [];
1620 $block_standard = empty($tags) || in_array('meta[name=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));
1625
1626 if ($block_standard) {
1627 add_filter('aioseo_description', '__return_empty_string', 999);
1628 }
1629 if ($block_og_desc) {
1630 add_filter('aioseo_facebook_tags', function($meta) {
1631 if (is_array($meta)) { unset($meta['og:description']); }
1632 return $meta;
1633 }, 999);
1634 }
1635 if ($block_tw_desc) {
1636 add_filter('aioseo_twitter_tags', function($meta) {
1637 if (is_array($meta)) { unset($meta['twitter:description']); }
1638 return $meta;
1639 }, 999);
1640 }
1641 }
1642 }
1643 }
1644 # Check whether OTTO is ALSO injected via JavaScript on the site (a misconfiguration
1645 # we warn admins about).
1646 #
1647 # The actual detection makes a loopback HTTP request to the site's OWN url. On hosts
1648 # that disallow same-server loopback (e.g. SiteGround), that request blocks for the
1649 # full timeout and then fails — and when it runs inline on admin_notices it makes
1650 # every wp-admin page hang. It must therefore NEVER run inline on an admin request.
1651 #
1652 # metasync_check_otto_js() is now read-only: it returns the cached result and, on a
1653 # cache miss, schedules a one-off BACKGROUND job (WP-Cron) to compute it. The notice
1654 # simply shows nothing until the background result is available.
1655 function metasync_check_otto_js(){
1656
1657 $cache_key = 'metasync_otto_js_detected';
1658 $cached = get_transient($cache_key);
1659
1660 if ($cached !== false) {
1661 return $cached === 'yes';
1662 }
1663
1664 # No cached result yet — compute it off the request so we never block admin
1665 # with a loopback call. Show nothing this load.
1666 metasync_schedule_otto_js_check();
1667 return false;
1668 };
1669
1670 # Queue the background loopback detection if it isn't already scheduled.
1671 function metasync_schedule_otto_js_check(){
1672 if (!wp_next_scheduled('metasync_otto_js_check_event')) {
1673 wp_schedule_single_event(time() + 5, 'metasync_otto_js_check_event');
1674 }
1675 }
1676
1677 # Background worker (runs in WP-Cron context, NOT on the admin request): performs the
1678 # blocking loopback request and caches the result. Hooked to metasync_otto_js_check_event.
1679 function metasync_run_otto_js_check(){
1680 $cache_key = 'metasync_otto_js_detected';
1681
1682 # the site url with the internal-fetch marker so OTTO does not re-process it
1683 $site_url = site_url() . '?is_otto_page_fetch=1';
1684
1685 $page_data = wp_remote_get($site_url, array('timeout' => 5, 'sslverify' => false));
1686
1687 if (is_wp_error($page_data)) {
1688 # Loopback failed (host likely blocks same-server requests). Cache "no" so we
1689 # don't keep re-queuing the check on every admin page load.
1690 set_transient($cache_key, 'no', HOUR_IN_SECONDS);
1691 return;
1692 }
1693
1694 $body = wp_remote_retrieve_body($page_data);
1695
1696 # Detect the OTTO script via a lightweight regex instead of parsing
1697 # the entire fetched page into a SimpleHtmlDom tree (which exhausted memory
1698 # on large pages — this check runs on admin page loads). We only need to
1699 # confirm a <script id="sa-dynamic-optimization" ... data-uuid="..."> exists.
1700 $found = preg_match(
1701 '/<script\b[^>]*\bid=["\']sa-dynamic-optimization["\'][^>]*\bdata-uuid=["\'][^"\']+["\']/i',
1702 (string) $body
1703 )
1704 || preg_match(
1705 # Attribute order may vary (data-uuid before id)
1706 '/<script\b[^>]*\bdata-uuid=["\'][^"\']+["\'][^>]*\bid=["\']sa-dynamic-optimization["\']/i',
1707 (string) $body
1708 );
1709
1710 if ($found) {
1711 set_transient($cache_key, 'yes', 12 * HOUR_IN_SECONDS);
1712 return;
1713 }
1714
1715 set_transient($cache_key, 'no', 12 * HOUR_IN_SECONDS);
1716 }
1717
1718 # Register the background worker hook.
1719 add_action('metasync_otto_js_check_event', 'metasync_run_otto_js_check');
1720
1721 # Handle AJAX Clear Cache request
1722 # NOTE: Cache system removed - this is now a no-op
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
1733 if (!empty($_GET['clear_otto_cache'])) {
1734 delete_transient('metasync_otto_js_detected');
1735 # Re-run the JS detection in the background so the notice refreshes.
1736 metasync_schedule_otto_js_check();
1737 # Cache system has been removed - no cache to clear
1738 wp_send_json_success(['message' => 'Cache system removed - all pages processed in real-time']);
1739 }
1740 else {
1741 wp_send_json_error(['message' => 'Missing parameter']);
1742 }
1743 }
1744
1745 # Clear cache hook
1746 add_action('wp_ajax_metasync_clear_otto_cache', 'metasync_clear_otto_cache_handler');
1747
1748 # add admin action to check script
1749 function metasync_show_otto_ssr_notice() {
1750 if (!Metasync::current_user_has_plugin_access()) {
1751 return; // Only show to admins
1752 }
1753
1754 # Get the plugin name using centralized method
1755 $plugin_name = Metasync::get_effective_plugin_name();
1756 $whitelabel_otto_name = Metasync::get_whitelabel_otto_name();
1757 if (metasync_check_otto_js()) {
1758
1759 # Show admin notice with plugin name included in the message
1760 echo '<div class="notice notice-error">
1761 <p><b>Warning from ' . esc_html($plugin_name) . '</b>
1762 <br>
1763 ' . esc_html($whitelabel_otto_name) . ' JavaScript has been detected on your site. Please remove it and configure ' . esc_html($whitelabel_otto_name) . ' for Wordpress. Contact support for help
1764 </p>
1765 </div>';
1766 }
1767 }
1768
1769 add_action('admin_notices', 'metasync_show_otto_ssr_notice');
1770
1771
1772 # staging dummy change
1773 # load otto in the wp hook
1774 add_action('wp', 'metasync_start_otto');
1775
1776 # ENHANCED OTTO SEO INTEGRATION
1777 # Register async SEO processing hook
1778 add_action('metasync_process_seo_job', 'metasync_process_otto_seo_data', 10, 4);
1779 add_action('metasync_process_otto_crawl_url_job', 'metasync_handle_otto_crawl_url_job', 10, 2);
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');
1782
1783 # Process OTTO SEO data and update WordPress meta fields for SEO plugins
1784 # This function now runs asynchronously via WordPress cron system
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 #
1791 # @param string $route Fully-qualified URL to process.
1792 # @param bool $allow_defer When false, skip CPU-deferral rescheduling (used when
1793 # called synchronously from crawl_url_job whose own retry
1794 # mechanism already handles failures).
1795 # @param int $deferral_count How many times this job has already been deferred for
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.
1801
1802 function metasync_process_otto_seo_data($route, $allow_defer = true, $deferral_count = 0, $prefetched_seo_data = null) {
1803 # Maximum number of times a job can be deferred before it is dropped.
1804 $max_deferrals = defined('METASYNC_SEO_JOB_MAX_DEFERRALS') ? METASYNC_SEO_JOB_MAX_DEFERRALS : 5;
1805 $lock_key = null;
1806 $lock_acquired = false;
1807
1808 try {
1809 # Validate input
1810 if (empty($route) || !is_string($route)) {
1811 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1812 }
1813
1814 # CPU load check — defer if server is under load.
1815 # Only reschedule when called from the cron hook (allow_defer=true) and
1816 # we haven't exceeded the maximum deferral count.
1817 # Checked BEFORE acquiring the lock so that deferred jobs don't
1818 # acquire-then-immediately-release it (which defeats concurrency protection).
1819 if (!Metasync_CPU_Monitor::is_load_safe()) {
1820 if ($allow_defer) {
1821 if ($deferral_count < $max_deferrals) {
1822 metasync_otto_schedule_single_event(
1823 time() + 60,
1824 'metasync_process_seo_job',
1825 array($route, true, $deferral_count + 1)
1826 );
1827 return Metasync_Otto_Job_Status::OUTCOME_DEFERRED;
1828 }
1829 # deferral budget exhausted — RUN the job late instead
1830 # of dropping it. The write is idempotent and bounded, and a
1831 # late sync is strictly better than SEO data that never
1832 # arrives. No new cron events are created, so the
1833 # anti-pile-up guarantee is preserved. Fall through.
1834 } else {
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;
1839 }
1840 }
1841
1842 # Concurrency lock: prevent multiple SEO jobs from running simultaneously.
1843 # Uses a per-URL transient lock with a 120s TTL as a safety net (the lock is
1844 # explicitly deleted on completion). If the lock exists another run is already
1845 # processing this URL — reschedule once with a short delay instead of stacking.
1846 $lock_key = 'metasync_seo_lock_' . md5($route);
1847 if (get_transient($lock_key) !== false) {
1848 if ($allow_defer && $deferral_count < $max_deferrals) {
1849 metasync_otto_schedule_single_event(
1850 time() + 30,
1851 'metasync_process_seo_job',
1852 array($route, true, $deferral_count + 1)
1853 );
1854 }
1855 return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
1856 }
1857 set_transient($lock_key, true, 120);
1858 $lock_acquired = true;
1859
1860 # Resolve redirect table: use final destination URL before 404 checks and OTTO processing
1861 $route = metasync_otto_resolve_redirect_to_final_url($route);
1862
1863 # Skip excluded URLs - don't process SEO data for them
1864 if (metasync_is_otto_url_excluded($route)) {
1865 //error_log('MetaSync OTTO: Skipping SEO processing for excluded URL: ' . $route);
1866 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1867 }
1868
1869 # Pre-flight 404 check: exclude URLs that would return 404 before making API call
1870 if (!metasync_otto_is_url_available($route)) {
1871 metasync_otto_auto_exclude_404_url($route);
1872 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1873 }
1874
1875 # Get OTTO UUID from settings
1876 # OPTIMIZED: Use cached options
1877 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
1878
1879 if (empty($otto_uuid)) {
1880 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1881 }
1882
1883 # Meta descriptions are always enabled by default - no check needed
1884
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 }
1907 }
1908
1909 # Get WordPress post ID from URL
1910 $post_id = url_to_postid($route);
1911
1912 # Special handling for WooCommerce shop page (url_to_postid doesn't work for it)
1913 if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) {
1914 # Check if this URL is the WooCommerce shop page
1915 $shop_page_id = wc_get_page_id('shop');
1916 if ($shop_page_id > 0) {
1917 $shop_url = get_permalink($shop_page_id);
1918 $route_normalized = rtrim($route, '/');
1919 $shop_url_normalized = rtrim($shop_url, '/');
1920
1921 if ($route_normalized === $shop_url_normalized) {
1922 $post_id = $shop_page_id;
1923 }
1924 }
1925 }
1926
1927 # Try to find WooCommerce product by URL if url_to_postid failed
1928 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
1929 # Extract product slug from URL
1930 $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1931
1932 # Try to get product by slug
1933 $products = wc_get_products(array(
1934 'name' => $product_slug,
1935 'limit' => 1,
1936 'status' => 'publish',
1937 ));
1938
1939 if (empty($products)) {
1940 # Fallback: try by slug using WP_Query
1941 $args = array(
1942 'post_type' => 'product',
1943 'name' => $product_slug,
1944 'posts_per_page' => 1,
1945 'post_status' => 'publish',
1946 );
1947 $query = new WP_Query($args);
1948
1949 if ($query->have_posts()) {
1950 $product_post = $query->posts[0];
1951 $post_id = $product_post->ID;
1952 }
1953 } else {
1954 $product = $products[0];
1955 $post_id = $product->get_id();
1956 }
1957 }
1958
1959 if (!$post_id || $post_id <= 0) {
1960 # Check if this is a category page
1961 if (strpos($route, '/category/') !== false) {
1962 # Extract category slug from URL
1963 $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1964 $category = get_category_by_slug($category_slug);
1965
1966 if ($category) {
1967 # Check if category would return 404 before applying OTTO changes
1968 if (metasync_would_term_return_404($category->term_id, 'category', $route)) {
1969 metasync_otto_auto_exclude_404_url($route);
1970 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1971 }
1972
1973 # Update comprehensive category SEO meta fields
1974 $update_result = metasync_update_comprehensive_category_seo_fields($category->term_id, $seo_data);
1975
1976 if ($update_result['updated']) {
1977 # Prepare trimmed values to 30 characters
1978 $trim = function($value) {
1979 if ($value === null) { return ''; }
1980 $value = (string) $value;
1981 $value = trim($value);
1982 if (mb_strlen($value) > 30) {
1983 return mb_substr($value, 0, 30);
1984 }
1985 return $value;
1986 };
1987
1988 # Log individual field updates for category
1989 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1990 $short = '';
1991 $title = '';
1992
1993 switch ($field_type) {
1994 case 'meta_title':
1995 $short = $trim($field_value);
1996 $title = "Category Meta Title Update ({$short}...)";
1997 break;
1998 case 'meta_description':
1999 $short = $trim($field_value);
2000 $title = "Category Meta Description Update ({$short}...)";
2001 break;
2002 case 'meta_keywords':
2003 $short = $trim($field_value);
2004 $title = "Category Meta Keywords Update ({$short}...)";
2005 break;
2006 case 'og_title':
2007 $short = $trim($field_value);
2008 $title = "Category Open Graph Title Update ({$short}...)";
2009 break;
2010 case 'og_description':
2011 $short = $trim($field_value);
2012 $title = "Category Open Graph Description Update ({$short}...)";
2013 break;
2014 case 'twitter_title':
2015 $short = $trim($field_value);
2016 $title = "Category Twitter Title Update ({$short}...)";
2017 break;
2018 case 'twitter_description':
2019 $short = $trim($field_value);
2020 $title = "Category Twitter Description Update ({$short}...)";
2021 break;
2022 case 'image_alt_data':
2023 $image_count = count($field_value);
2024 $title = "Category Image Alt Text Update ({$image_count} images)";
2025 break;
2026 case 'headings_data':
2027 $heading_count = count($field_value);
2028 $title = "Category Headings Update ({$heading_count} headings)";
2029 break;
2030 case 'structured_data':
2031 $title = 'Category Structured Data Update';
2032 break;
2033 }
2034
2035 if (is_scalar($field_value) && trim((string) $field_value) === '') {
2036 $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2037 }
2038
2039 if (!empty($title)) {
2040 metasync_log_sync_history([
2041 'title' => $title,
2042 'source' => 'OTTO SEO',
2043 'status' => 'published',
2044 'content_type' => 'Category SEO',
2045 'url' => $route,
2046 'meta_data' => json_encode([
2047 'field' => $field_type,
2048 'field_value' => $field_value,
2049 'category_id' => $category->term_id,
2050 'category_name' => $category->name
2051 ])
2052 ]);
2053 }
2054 }
2055
2056 return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
2057 }
2058
2059 return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
2060 }
2061 }
2062
2063 # Check if this is a WooCommerce product category
2064 if (strpos($route, '/product-category/') !== false) {
2065 # Extract product category slug from URL
2066 $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2067 $term = get_term_by('slug', $category_slug, 'product_cat');
2068
2069 if ($term && !is_wp_error($term)) {
2070 # Check if product category would return 404 before applying OTTO changes
2071 if (metasync_would_term_return_404($term->term_id, 'product_cat', $route)) {
2072 metasync_otto_auto_exclude_404_url($route);
2073 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2074 }
2075
2076 # Update comprehensive taxonomy SEO meta fields
2077 $update_result = metasync_update_comprehensive_taxonomy_seo_fields($term->term_id, 'product_cat', $seo_data);
2078
2079 if ($update_result['updated']) {
2080 # Prepare trimmed values to 30 characters
2081 $trim = function($value) {
2082 if ($value === null) { return ''; }
2083 $value = (string) $value;
2084 $value = trim($value);
2085 if (mb_strlen($value) > 30) {
2086 return mb_substr($value, 0, 30);
2087 }
2088 return $value;
2089 };
2090
2091 # Log individual field updates for product category
2092 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
2093 $short = '';
2094 $title = '';
2095
2096 switch ($field_type) {
2097 case 'meta_title':
2098 $short = $trim($field_value);
2099 $title = "Product Category Meta Title Update ({$short}...)";
2100 break;
2101 case 'meta_description':
2102 $short = $trim($field_value);
2103 $title = "Product Category Meta Description Update ({$short}...)";
2104 break;
2105 case 'meta_keywords':
2106 $short = $trim($field_value);
2107 $title = "Product Category Meta Keywords Update ({$short}...)";
2108 break;
2109 case 'og_title':
2110 $short = $trim($field_value);
2111 $title = "Product Category Open Graph Title Update ({$short}...)";
2112 break;
2113 case 'og_description':
2114 $short = $trim($field_value);
2115 $title = "Product Category Open Graph Description Update ({$short}...)";
2116 break;
2117 case 'twitter_title':
2118 $short = $trim($field_value);
2119 $title = "Product Category Twitter Title Update ({$short}...)";
2120 break;
2121 case 'twitter_description':
2122 $short = $trim($field_value);
2123 $title = "Product Category Twitter Description Update ({$short}...)";
2124 break;
2125 case 'image_alt_data':
2126 $image_count = count($field_value);
2127 $title = "Product Category Image Alt Text Update ({$image_count} images)";
2128 break;
2129 case 'headings_data':
2130 $heading_count = count($field_value);
2131 $title = "Product Category Headings Update ({$heading_count} headings)";
2132 break;
2133 case 'structured_data':
2134 $title = 'Product Category Structured Data Update';
2135 break;
2136 }
2137
2138 if (is_scalar($field_value) && trim((string) $field_value) === '') {
2139 $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2140 }
2141
2142 if (!empty($title)) {
2143 metasync_log_sync_history([
2144 'title' => $title,
2145 'source' => 'OTTO SEO',
2146 'status' => 'published',
2147 'content_type' => 'WooCommerce Product Category SEO',
2148 'url' => $route,
2149 'meta_data' => json_encode([
2150 'field' => $field_type,
2151 'field_value' => $field_value,
2152 'term_id' => $term->term_id,
2153 'term_name' => $term->name,
2154 'taxonomy' => 'product_cat'
2155 ])
2156 ]);
2157 }
2158 }
2159
2160 return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
2161 }
2162
2163 return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
2164 }
2165 }
2166
2167 # Check if this is the home page (landing page)
2168 $site_url = rtrim(site_url(), '/');
2169 $route_clean = rtrim($route, '/');
2170
2171 if ($route_clean === $site_url) {
2172 # Get the home page (front page)
2173 $front_page_id = get_option('page_on_front');
2174 $home_page = null;
2175
2176 if ($front_page_id && $front_page_id > 0) {
2177 $home_page = get_post($front_page_id);
2178 } else {
2179 # If no static front page is set, get the latest post
2180 $home_page = get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null;
2181 }
2182
2183 if ($home_page) {
2184 # Check if home page would return 404 before applying OTTO changes
2185 if (metasync_would_page_return_404($home_page->ID, $route)) {
2186 metasync_otto_auto_exclude_404_url($route);
2187 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2188 }
2189
2190 # Update comprehensive home page SEO meta fields
2191 $update_result = metasync_update_comprehensive_seo_fields($home_page->ID, $seo_data);
2192
2193 if ($update_result['updated']) {
2194 # Clear relevant caches
2195 metasync_clear_post_seo_caches($home_page->ID);
2196
2197 # Prepare trimmed values to 30 characters
2198 $trim = function($value) {
2199 if ($value === null) { return ''; }
2200 $value = (string) $value;
2201 $value = trim($value);
2202 if (mb_strlen($value) > 30) {
2203 return mb_substr($value, 0, 30);
2204 }
2205 return $value;
2206 };
2207
2208 # Log individual field updates for home page
2209 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
2210 $short = '';
2211 $title = '';
2212
2213 switch ($field_type) {
2214 case 'meta_title':
2215 $short = $trim($field_value);
2216 $title = "Home Page Meta Title Update ({$short}...)";
2217 break;
2218 case 'meta_description':
2219 $short = $trim($field_value);
2220 $title = "Home Page Meta Description Update ({$short}...)";
2221 break;
2222 case 'meta_keywords':
2223 $short = $trim($field_value);
2224 $title = "Home Page Meta Keywords Update ({$short}...)";
2225 break;
2226 case 'og_title':
2227 $short = $trim($field_value);
2228 $title = "Home Page Open Graph Title Update ({$short}...)";
2229 break;
2230 case 'og_description':
2231 $short = $trim($field_value);
2232 $title = "Home Page Open Graph Description Update ({$short}...)";
2233 break;
2234 case 'twitter_title':
2235 $short = $trim($field_value);
2236 $title = "Home Page Twitter Title Update ({$short}...)";
2237 break;
2238 case 'twitter_description':
2239 $short = $trim($field_value);
2240 $title = "Home Page Twitter Description Update ({$short}...)";
2241 break;
2242 case 'image_alt_data':
2243 $image_count = count($field_value);
2244 $title = "Home Page Image Alt Text Update ({$image_count} images)";
2245 break;
2246 case 'headings_data':
2247 $heading_count = count($field_value);
2248 $title = "Home Page Headings Update ({$heading_count} headings)";
2249 break;
2250 case 'structured_data':
2251 $title = 'Home Page Structured Data Update';
2252 break;
2253 }
2254
2255 if (is_scalar($field_value) && trim((string) $field_value) === '') {
2256 $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2257 }
2258
2259 if (!empty($title)) {
2260 metasync_log_sync_history([
2261 'title' => $title,
2262 'source' => 'OTTO SEO',
2263 'status' => 'published',
2264 'content_type' => 'Home Page SEO',
2265 'url' => $route,
2266 'meta_data' => json_encode([
2267 'field' => $field_type,
2268 'field_value' => $field_value,
2269 'post_id' => $home_page->ID
2270 ])
2271 ]);
2272 }
2273 }
2274
2275 return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
2276 }
2277
2278 return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
2279 }
2280 }
2281
2282 # Check if this is the blog/posts page (page_for_posts)
2283 $posts_page_id = intval(get_option('page_for_posts'));
2284 if ($posts_page_id > 0) {
2285 $posts_page = get_post($posts_page_id);
2286 $posts_page_url = rtrim(get_permalink($posts_page_id), '/');
2287
2288 if ($posts_page && $route_clean === $posts_page_url) {
2289 # Check if blog page would return 404
2290 if (metasync_would_page_return_404($posts_page->ID, $route)) {
2291 metasync_otto_auto_exclude_404_url($route);
2292 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2293 }
2294
2295 # Update comprehensive blog page SEO meta fields
2296 $update_result = metasync_update_comprehensive_seo_fields($posts_page->ID, $seo_data);
2297
2298 if ($update_result['updated']) {
2299 # Clear relevant caches
2300 metasync_clear_post_seo_caches($posts_page->ID);
2301
2302 # Prepare trimmed values to 30 characters
2303 $trim = function($value) {
2304 if ($value === null) { return ''; }
2305 $value = (string) $value;
2306 $value = trim($value);
2307 if (mb_strlen($value) > 30) {
2308 return mb_substr($value, 0, 30);
2309 }
2310 return $value;
2311 };
2312
2313 # Log individual field updates for blog page
2314 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
2315 $short = '';
2316 $title = '';
2317
2318 switch ($field_type) {
2319 case 'meta_title':
2320 $short = $trim($field_value);
2321 $title = "Blog Page Meta Title Update ({$short}...)";
2322 break;
2323 case 'meta_description':
2324 $short = $trim($field_value);
2325 $title = "Blog Page Meta Description Update ({$short}...)";
2326 break;
2327 case 'meta_keywords':
2328 $short = $trim($field_value);
2329 $title = "Blog Page Meta Keywords Update ({$short}...)";
2330 break;
2331 case 'og_title':
2332 $short = $trim($field_value);
2333 $title = "Blog Page Open Graph Title Update ({$short}...)";
2334 break;
2335 case 'og_description':
2336 $short = $trim($field_value);
2337 $title = "Blog Page Open Graph Description Update ({$short}...)";
2338 break;
2339 case 'twitter_title':
2340 $short = $trim($field_value);
2341 $title = "Blog Page Twitter Title Update ({$short}...)";
2342 break;
2343 case 'twitter_description':
2344 $short = $trim($field_value);
2345 $title = "Blog Page Twitter Description Update ({$short}...)";
2346 break;
2347 case 'image_alt_data':
2348 $image_count = count($field_value);
2349 $title = "Blog Page Image Alt Text Update ({$image_count} images)";
2350 break;
2351 case 'headings_data':
2352 $heading_count = count($field_value);
2353 $title = "Blog Page Headings Update ({$heading_count} headings)";
2354 break;
2355 case 'structured_data':
2356 $title = 'Blog Page Structured Data Update';
2357 break;
2358 }
2359
2360 if (is_scalar($field_value) && trim((string) $field_value) === '') {
2361 $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2362 }
2363
2364 if (!empty($title)) {
2365 metasync_log_sync_history([
2366 'title' => $title,
2367 'source' => 'OTTO SEO',
2368 'status' => 'published',
2369 'content_type' => 'Blog Page SEO',
2370 'url' => $route,
2371 'meta_data' => json_encode([
2372 'field' => $field_type,
2373 'field_value' => $field_value,
2374 'post_id' => $posts_page->ID
2375 ])
2376 ]);
2377 }
2378 }
2379
2380 return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
2381 }
2382
2383 return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
2384 }
2385 }
2386
2387 # URL didn't resolve to any supported entity (post, category, home page, blog page)
2388 # Treat as 404 and auto-exclude (e.g. deleted post, non-existent page)
2389 if (!metasync_otto_is_url_available($route)) {
2390 metasync_otto_auto_exclude_404_url($route);
2391 }
2392 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2393 }
2394
2395 # Verify this is actually a post, page, or WooCommerce product
2396 $post = get_post($post_id);
2397
2398 # Get supported post types dynamically
2399 $supported_post_types = metasync_get_supported_post_types();
2400
2401 if (!$post || !in_array($post->post_type, $supported_post_types)) {
2402 # Skip unsupported post types
2403 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2404 }
2405
2406 # Check if page would return 404 before applying OTTO changes
2407 if (metasync_would_page_return_404($post_id, $route)) {
2408 metasync_otto_auto_exclude_404_url($route);
2409 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2410 }
2411
2412 # Update comprehensive SEO meta fields
2413 $update_result = metasync_update_comprehensive_seo_fields($post_id, $seo_data);
2414
2415 if ($update_result['updated']) {
2416 # Clear relevant caches
2417 metasync_clear_post_seo_caches($post_id);
2418
2419 # Prepare trimmed values to 30 characters
2420 $trim = function($value) {
2421 if ($value === null) { return ''; }
2422 $value = (string) $value;
2423 $value = trim($value);
2424 if (mb_strlen($value) > 30) {
2425 return mb_substr($value, 0, 30);
2426 }
2427 return $value;
2428 };
2429
2430 # Log individual field updates
2431 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
2432 $short = '';
2433 $title = '';
2434
2435 switch ($field_type) {
2436 case 'meta_title':
2437 $short = $trim($field_value);
2438 $title = 'Meta Title Update (' . $short . '...)';
2439 break;
2440 case 'meta_description':
2441 $short = $trim($field_value);
2442 $title = 'Meta Description Update (' . $short . '...)';
2443 break;
2444 case 'meta_keywords':
2445 $short = $trim($field_value);
2446 $title = 'Meta Keywords Update (' . $short . '...)';
2447 break;
2448 case 'og_title':
2449 $short = $trim($field_value);
2450 $title = 'Open Graph Title Update (' . $short . '...)';
2451 break;
2452 case 'og_description':
2453 $short = $trim($field_value);
2454 $title = 'Open Graph Description Update (' . $short . '...)';
2455 break;
2456 case 'twitter_title':
2457 $short = $trim($field_value);
2458 $title = 'Twitter Title Update (' . $short . '...)';
2459 break;
2460 case 'twitter_description':
2461 $short = $trim($field_value);
2462 $title = 'Twitter Description Update (' . $short . '...)';
2463 break;
2464 case 'image_alt_data':
2465 $image_count = count($field_value);
2466 $title = "Image Alt Text Update ({$image_count} images)";
2467 break;
2468 case 'headings_data':
2469 $heading_count = count($field_value);
2470 $title = "Headings Update ({$heading_count} headings)";
2471 break;
2472 case 'structured_data':
2473 $title = 'Structured Data Update';
2474 break;
2475 }
2476
2477 if (is_scalar($field_value) && trim((string) $field_value) === '') {
2478 $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2479 }
2480
2481 if (!empty($title)) {
2482 metasync_log_sync_history([
2483 'title' => $title,
2484 'source' => 'OTTO SEO',
2485 'status' => 'published',
2486 'content_type' => 'SEO Meta',
2487 'url' => $route,
2488 'meta_data' => json_encode([
2489 'field' => $field_type,
2490 'field_value' => $field_value,
2491 'post_id' => $post_id
2492 ])
2493 ]);
2494 }
2495 }
2496
2497 return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
2498 }
2499
2500 return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
2501
2502 } catch (Exception $e) {
2503 metasync_record_failed_action( 'metasync_process_seo_job' );
2504 return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
2505 } finally {
2506 # Release the concurrency lock only if we actually acquired it.
2507 # Early returns (CPU deferral, lock contention) must NOT delete a lock
2508 # that another process may be holding.
2509 if ($lock_acquired && $lock_key) {
2510 delete_transient($lock_key);
2511 }
2512 }
2513 }
2514
2515 /**
2516 * Log sync history entry
2517 * @param array $data Sync data to log
2518 */
2519 function metasync_log_sync_history($data) {
2520 try {
2521 // Classes are now autoloaded, no need for manual require
2522 $sync_db = new Metasync_Sync_History_Database();
2523
2524 // Minimal duplicate prevention within short time window
2525 if (!empty($data['title']) && !empty($data['source'])) {
2526 global $wpdb;
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",
2529 $data['title'],
2530 $data['source'],
2531 gmdate('Y-m-d H:i:s', time() - 60)
2532 ));
2533 if ((int)$recent > 0) {
2534 return; // skip duplicate log within 60 seconds
2535 }
2536 }
2537
2538 $sync_db->add($data);
2539
2540 } catch (Exception $e) {
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
2542 }
2543 }
2544
2545 /**
2546 * Get supported post types for OTTO SEO optimization
2547 * Includes WooCommerce products if WooCommerce is active
2548 *
2549 * @return array List of supported post types
2550 */
2551 function metasync_get_supported_post_types() {
2552 # Start with default post types
2553 $post_types = ['post', 'page'];
2554
2555 # Add WooCommerce product post type if WooCommerce is active
2556 if (class_exists('WooCommerce') || function_exists('is_woocommerce')) {
2557 $post_types[] = 'product';
2558 }
2559
2560 # Include all public custom post types (e.g. 'location', 'service', 'team', etc.)
2561 # so OTTO can write post meta for them during metasync_process_otto_seo_data().
2562 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
2563 if (!empty($custom_post_types)) {
2564 $post_types = array_merge($post_types, array_values($custom_post_types));
2565 }
2566
2567 # Allow developers to filter supported post types
2568 $post_types = apply_filters('metasync_otto_supported_post_types', $post_types);
2569
2570 return $post_types;
2571 }
2572
2573 /**
2574 * Get supported taxonomies for OTTO SEO optimization
2575 * Includes WooCommerce product categories and tags if WooCommerce is active
2576 *
2577 * @return array List of supported taxonomies
2578 */
2579 function metasync_get_supported_taxonomies() {
2580 # Start with default taxonomies
2581 $taxonomies = ['category'];
2582
2583 # Add WooCommerce taxonomies if WooCommerce is active
2584 if (class_exists('WooCommerce') || function_exists('is_woocommerce')) {
2585 $taxonomies[] = 'product_cat'; # WooCommerce product categories
2586 $taxonomies[] = 'product_tag'; # WooCommerce product tags
2587 }
2588
2589 # Allow developers to filter supported taxonomies
2590 $taxonomies = apply_filters('metasync_otto_supported_taxonomies', $taxonomies);
2591
2592 return $taxonomies;
2593 }
2594
2595 /**
2596 * Resolve a URL through the Redirect Manager table to its final destination (follows redirect chains).
2597 * Used before 404 checks and OTTO processing so the final canonical URL is used, not intermediate redirects.
2598 *
2599 * @param string $url Full URL (e.g. https://example.com/old-page)
2600 * @return string Final destination URL, or original $url if no redirect matches
2601 */
2602 function metasync_otto_resolve_redirect_to_final_url($url)
2603 {
2604 if (empty($url) || !is_string($url)) {
2605 return $url;
2606 }
2607 try {
2608 $db_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection-database.php';
2609 $class_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection.php';
2610 if (!file_exists($db_path) || !file_exists($class_path)) {
2611 return $url;
2612 }
2613 require_once $db_path;
2614 require_once $class_path;
2615 $db = new Metasync_Redirection_Database();
2616 $redirect = new Metasync_Redirection($db);
2617 return $redirect->resolve_url_to_final_destination($url, 10);
2618 } catch (Exception $e) {
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
2620 return $url;
2621 }
2622 }
2623
2624 /**
2625 * Auto-exclude a URL from OTTO with description "Auto-excluded: 404"
2626 * Called when a URL is detected as returning 404 so it won't be sent to OTTO again
2627 *
2628 * @param string $url Full URL to exclude (e.g. https://example.com/404-page)
2629 * @return bool|string True on success, false on failure, 'duplicate'/'reactivated' if already exists
2630 */
2631 function metasync_otto_auto_exclude_404_url($url)
2632 {
2633 if (empty($url) || !is_string($url)) {
2634 return false;
2635 }
2636 $url = filter_var($url, FILTER_SANITIZE_URL);
2637 $url = esc_url_raw($url);
2638 if (empty($url) || mb_strlen($url) > 2048) {
2639 return false;
2640 }
2641 try {
2642 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2643 $db = new Metasync_Otto_Excluded_URLs_Database();
2644 return $db->add([
2645 'url_pattern' => $url,
2646 'pattern_type' => 'exact',
2647 'description' => 'Auto-excluded: 404',
2648 'status' => 'active',
2649 'auto_excluded' => 1,
2650 ]);
2651 } catch (Exception $e) {
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
2653 return false;
2654 }
2655 }
2656
2657 /**
2658 * Remove a URL from the OTTO auto-exclusion list.
2659 * Called when OTTO sends a webhook for a URL, confirming it is valid and crawlable.
2660 * Only removes records where auto_excluded = 1 (never removes manual exclusions).
2661 *
2662 * @param string $url Full URL to un-exclude (e.g. https://example.com/location/page)
2663 * @return bool True on success
2664 */
2665 function metasync_otto_remove_auto_exclusion($url)
2666 {
2667 if (empty($url) || !is_string($url)) {
2668 return false;
2669 }
2670 try {
2671 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2672 $db = new Metasync_Otto_Excluded_URLs_Database();
2673 global $wpdb;
2674 # Normalize the same way is_url_excluded() does
2675 $url_normalized = rtrim(trim($url), '/');
2676 $records = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- webhook-path cleanup of auto-exclusions over the plugin custom table
2677 $wpdb->prepare(
2678 "SELECT id FROM `{$wpdb->prefix}metasync_otto_excluded_urls` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'",
2679 $url_normalized
2680 )
2681 );
2682 if (!empty($records)) {
2683 $ids = array_map(function ($r) { return (int) $r->id; }, $records);
2684 $db->delete($ids);
2685 }
2686 return true;
2687 } catch (Exception $e) {
2688 return false;
2689 }
2690 }
2691
2692 /**
2693 * Check if a URL is MANUALLY excluded from OTTO (auto_excluded = 0).
2694 * Used at render time (metasync_start_otto) — auto-exclusions must NOT block
2695 * rendering because they are often false positives (e.g. custom post types that
2696 * url_to_postid() can't resolve). Auto-exclusions are only used to gate the
2697 * SEO meta-writing webhook path.
2698 *
2699 * @param string $url URL to check
2700 * @return bool True if URL has a manual exclusion
2701 */
2702 function metasync_is_otto_url_manually_excluded($url)
2703 {
2704 if (empty($url) || !is_string($url)) {
2705 return false;
2706 }
2707 try {
2708 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2709 global $wpdb;
2710 $url_normalized = rtrim(trim($url), '/');
2711
2712 $records = get_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY);
2713
2714 if ($records === false) {
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"
2717 );
2718
2719 // Graceful recovery: auto_excluded column missing on pre-v2.7.4 installs.
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).
2724 if ($wpdb->last_error && strpos($wpdb->last_error, 'auto_excluded') !== false) {
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 }
2728 return false;
2729 }
2730
2731 set_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY, $records ?: [], METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL);
2732 }
2733
2734 if (empty($records)) {
2735 return false;
2736 }
2737
2738 foreach ($records as $excluded) {
2739 # Delegate to the shared matcher so this gate supports every pattern type the
2740 # add handler accepts (exact|contain|start|end|regex). Keeping a second switch
2741 # here is what let `end` and `regex` exclusions be silently ignored on the
2742 # visitor render path while still applying on the queue/SEO-write path.
2743 # The raw pattern is passed through - the matcher normalizes it per type,
2744 # because a regex must keep the trailing slash that closes its delimiters.
2745 if (Metasync_Otto_Excluded_URLs_Database::pattern_matches($url_normalized, $excluded->url_pattern, $excluded->pattern_type)) {
2746 return true;
2747 }
2748 }
2749 return false;
2750 } catch (Exception $e) {
2751 return false;
2752 }
2753 }
2754
2755 /**
2756 * Check if a URL is excluded from OTTO
2757 * @param string $url URL to check
2758 * @return bool True if URL is excluded, false otherwise
2759 */
2760 function metasync_is_otto_url_excluded($url)
2761 {
2762 try {
2763 // Load database class
2764 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2765 $db = new Metasync_Otto_Excluded_URLs_Database();
2766
2767 // Check if URL is excluded
2768 return $db->is_url_excluded($url);
2769
2770 } catch (Exception $e) {
2771 return false;
2772 }
2773 }
2774
2775 /**
2776 * Check if a URL is now available (would NOT return 404)
2777 * Uses same resolution logic as metasync_process_otto_seo_data
2778 * Used when rechecking auto-excluded 404 URLs after 7 days
2779 *
2780 * @param string $url Full URL to check (e.g. https://example.com/page)
2781 * @return bool True if URL is accessible, false if it would return 404
2782 */
2783 function metasync_otto_is_url_available($url)
2784 {
2785 if (empty($url) || !is_string($url)) {
2786 return false;
2787 }
2788
2789 $route = metasync_otto_resolve_redirect_to_final_url($url);
2790 $post_id = url_to_postid($route);
2791
2792 # WooCommerce shop page
2793 if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) {
2794 $shop_page_id = wc_get_page_id('shop');
2795 if ($shop_page_id > 0) {
2796 $shop_url = get_permalink($shop_page_id);
2797 if (rtrim($route, '/') === rtrim($shop_url, '/')) {
2798 $post_id = $shop_page_id;
2799 }
2800 }
2801 }
2802
2803 # WooCommerce product by slug
2804 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
2805 $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2806 $products = wc_get_products(array('name' => $product_slug, 'limit' => 1, 'status' => 'publish'));
2807 if (!empty($products)) {
2808 $post_id = $products[0]->get_id();
2809 } else {
2810 $query = new WP_Query(array(
2811 'post_type' => 'product',
2812 'name' => $product_slug,
2813 'posts_per_page' => 1,
2814 'post_status' => 'publish',
2815 ));
2816 if ($query->have_posts()) {
2817 $post_id = $query->posts[0]->ID;
2818 }
2819 }
2820 }
2821
2822 if ($post_id && $post_id > 0) {
2823 $post = get_post($post_id);
2824 # Accept any post type — custom post types (e.g. 'location', 'service') are valid URLs.
2825 # The old in_array check against metasync_get_supported_post_types() caused CPT URLs
2826 # to be wrongly auto-excluded as "404" pages.
2827 if ($post) {
2828 return !metasync_would_page_return_404($post_id, $route);
2829 }
2830 }
2831
2832 # Category
2833 if (strpos($route, '/category/') !== false) {
2834 $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2835 $category = get_category_by_slug($category_slug);
2836 if ($category) {
2837 return !metasync_would_term_return_404($category->term_id, 'category', $route);
2838 }
2839 }
2840
2841 # WooCommerce product category
2842 if (strpos($route, '/product-category/') !== false) {
2843 $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2844 $term = get_term_by('slug', $category_slug, 'product_cat');
2845 if ($term && !is_wp_error($term)) {
2846 return !metasync_would_term_return_404($term->term_id, 'product_cat', $route);
2847 }
2848 }
2849
2850 # Home page
2851 if (rtrim($route, '/') === rtrim(site_url(), '/')) {
2852 $front_page_id = get_option('page_on_front');
2853 $home_page = ($front_page_id && $front_page_id > 0)
2854 ? get_post($front_page_id)
2855 : (get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null);
2856 if ($home_page) {
2857 return !metasync_would_page_return_404($home_page->ID, $route);
2858 }
2859 }
2860
2861 # Could not verify availability from local data (custom archive, paginated page, etc.).
2862 # Assume the URL IS available — OTTO only crawls reachable URLs, so if we can't
2863 # prove it's a 404, we should not auto-exclude it.
2864 return true;
2865 }
2866
2867 /**
2868 * Recheck auto-excluded 404 URLs when recheck_after has passed; remove from exclusion if now available
2869 * Uses recheck_after timestamp (default 7 days from exclusion) to decide when to recheck
2870 * Mark as permanent after 30 days if still 404 (no further rechecks)
2871 * Called by daily cron job
2872 */
2873 function metasync_otto_recheck_404_exclusions()
2874 {
2875 try {
2876 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2877 $db = new Metasync_Otto_Excluded_URLs_Database();
2878 $records = $db->get_auto_excluded_404_urls_due_for_recheck();
2879
2880 if (empty($records)) {
2881 return;
2882 }
2883
2884 $removed = 0;
2885 $marked_permanent = 0;
2886 $thirty_days_ago = strtotime('-30 days');
2887 $next_recheck = gmdate('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS);
2888
2889 foreach ($records as $record) {
2890 $url = trim($record->url_pattern);
2891 if (empty($url)) {
2892 continue;
2893 }
2894 if (metasync_otto_is_url_available($url)) {
2895 $db->delete([$record->id]);
2896 $removed++;
2897 } else {
2898 # Still 404: if excluded 30+ days ago, mark as permanent (no more rechecks)
2899 $created_ts = strtotime($record->created_at);
2900 if ($created_ts <= $thirty_days_ago) {
2901 $db->update(['is_permanent' => 1], $record->id);
2902 $marked_permanent++;
2903 } else {
2904 # Schedule next recheck in 7 days
2905 $db->update(['recheck_after' => $next_recheck], $record->id);
2906 }
2907 }
2908 }
2909
2910 } catch (Exception $e) {
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
2912 }
2913 }
2914
2915 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
2916
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 /**
2934 * Check if a post/page would return 404 without making HTTP request
2935 * Uses WordPress database checks for fast validation
2936 *
2937 * @param int $post_id WordPress post ID
2938 * @param string $url The URL being checked (optional, for logging)
2939 * @return bool True if page would return 404, false if accessible
2940 */
2941 function metasync_would_page_return_404($post_id, $url = '') {
2942 if (!$post_id || $post_id <= 0) {
2943 return true; // No post ID = 404
2944 }
2945
2946 # Get the post object
2947 $post = get_post($post_id);
2948 if (!$post) {
2949 return true; // Post doesn't exist = 404
2950 }
2951
2952 # 1. Check post status - must be 'publish' to be publicly accessible
2953 if ($post->post_status !== 'publish') {
2954 return true; // Draft, pending, private, etc. = 404
2955 }
2956
2957 # 2. Check if post is password protected (requires password to view)
2958 if (!empty($post->post_password)) {
2959 # Password protected posts are not publicly accessible without password
2960 return true; // Password protected = effectively 404 for public
2961 }
2962
2963 # 3. Check if post is in trash
2964 if ($post->post_status === 'trash') {
2965 return true; // Trashed = 404
2966 }
2967
2968 # 4. Check if post type is publicly queryable
2969 $post_type_object = get_post_type_object($post->post_type);
2970 if ($post_type_object && !$post_type_object->publicly_queryable) {
2971 # Some post types might not be publicly accessible
2972 # But we allow if it's in our supported types
2973 $supported_post_types = metasync_get_supported_post_types();
2974 if (!in_array($post->post_type, $supported_post_types)) {
2975 return true; // Not publicly queryable = 404
2976 }
2977 }
2978
2979 # 5. WordPress 5.7+ has a built-in function for this
2980 if (function_exists('is_post_publicly_viewable')) {
2981 if (!is_post_publicly_viewable($post)) {
2982 return true; // Not publicly viewable = 404
2983 }
2984 }
2985
2986 # 6. Check if post is scheduled for future (not yet published)
2987 if ($post->post_date > current_time('mysql')) {
2988 return true; // Future post = 404 until publish date
2989 }
2990
2991 # All checks passed - page should be accessible
2992 return false;
2993 }
2994
2995 /**
2996 * Check if a taxonomy term (category, tag, etc.) would return 404
2997 * Uses WordPress database checks for fast validation
2998 *
2999 * @param int $term_id Term ID
3000 * @param string $taxonomy Taxonomy name (e.g., 'category', 'product_cat')
3001 * @param string $url The URL being checked (optional, for logging)
3002 * @return bool True if term would return 404, false if accessible
3003 */
3004 function metasync_would_term_return_404($term_id, $taxonomy, $url = '') {
3005 if (!$term_id || $term_id <= 0 || empty($taxonomy)) {
3006 return true; // Invalid term = 404
3007 }
3008
3009 # Get the term object
3010 $term = get_term($term_id, $taxonomy);
3011 if (is_wp_error($term) || !$term) {
3012 return true; // Term doesn't exist = 404
3013 }
3014
3015 # Check if taxonomy is publicly queryable
3016 $taxonomy_object = get_taxonomy($taxonomy);
3017 if (!$taxonomy_object || !$taxonomy_object->public) {
3018 # Check if it's in our supported taxonomies
3019 $supported_taxonomies = metasync_get_supported_taxonomies();
3020 if (!in_array($taxonomy, $supported_taxonomies)) {
3021 return true; // Not publicly queryable = 404
3022 }
3023 }
3024
3025 # Terms are generally always accessible if they exist and taxonomy is public
3026 # WordPress doesn't have a "draft" status for terms like posts do
3027 # But we can check if the term has a count (has posts assigned)
3028 # Empty terms might not be useful, but they're still accessible
3029
3030 # All checks passed - term should be accessible
3031 return false;
3032 }
3033
3034 /**
3035 * Invalidate Brizy posts cache when posts are saved
3036 * OPTIMIZATION: Clears transient cache to ensure accurate detection
3037 */
3038 add_action('save_post', function($post_id) {
3039 # Check if this post has Brizy metadata
3040 if (get_post_meta($post_id, 'brizy_post_uid', true)) {
3041 delete_transient('metasync_has_brizy_posts');
3042 }
3043 }, 10, 1);
3044
3045 /**
3046 * Identify infrastructure agents that must never hit the render throttle.
3047 *
3048 * Two classes of agent are covered:
3049 *
3050 * - Page-cache warmers. A host preloader (WP Cloud PageCacheBot, WP Rocket preload,
3051 * LiteSpeed, SG Optimizer, ...) exists purely to populate the page cache. Throttling
3052 * one returns un-OTTO'd HTML *and* defines DONOTCACHEPAGE, so the cache can never be
3053 * filled and every subsequent human visitor pays a full uncached render. Measured on
3054 * the reporting site: desktop cache MISS on 17/17 requests at ~2.8s TTFB, versus
3055 * ~0.56s with OTTO disabled.
3056 *
3057 * - Synthetic performance auditors (Lighthouse / PageSpeed Insights / GTmetrix).
3058 * These are what customers measure with, so they must receive the same fully
3059 * rendered OTTO output a real visitor gets. Currently `/lighthouse/i` and
3060 * `/pagespeed/i` are generic-bot patterns, so a second PSI run inside the 5-minute
3061 * window is served the throttled, un-OTTO'd, uncacheable page.
3062 *
3063 * Matching is done on the raw user-agent, deliberately independent of the bot-name
3064 * categorisation in Metasync_Otto_Bot_Detector.
3065 *
3066 * @param mixed $detection Result of Metasync_Otto_Bot_Detector::detect(), or malformed input.
3067 * @return bool True when this request must bypass the render throttle.
3068 */
3069 function metasync_otto_is_unthrottled_infrastructure_agent( $detection = array() ) {
3070
3071 $ua = '';
3072 if ( is_array( $detection ) && ! empty( $detection['user_agent'] ) ) {
3073 $ua = (string) $detection['user_agent'];
3074 } elseif ( isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
3075 $ua = (string) $_SERVER['HTTP_USER_AGENT'];
3076 }
3077
3078 $needles = array(
3079 // Page-cache preloaders.
3080 'pagecachebot', // WP Cloud / Automattic (PageCacheBotDesktop|Mobile)
3081 'wp rocket', // WP Rocket preload
3082 'wprocket',
3083 'lscache_runner', // LiteSpeed Cache crawler
3084 'sg-optimizer', // SiteGround
3085 'sgoptimizer',
3086 'nitropack',
3087 'cache-warmer',
3088 'cachewarmer',
3089 'cache warmer',
3090 // Synthetic performance auditors.
3091 'lighthouse',
3092 'pagespeed',
3093 'gtmetrix',
3094 'pingdom',
3095 'webpagetest',
3096 );
3097
3098 $is_infra = false;
3099
3100 if ( '' !== $ua ) {
3101 $ua_lower = strtolower( $ua );
3102 foreach ( $needles as $needle ) {
3103 if ( false !== strpos( $ua_lower, $needle ) ) {
3104 $is_infra = true;
3105 break;
3106 }
3107 }
3108 }
3109
3110 // WP Cloud's warmer also tags its requests with a query marker.
3111 if ( ! $is_infra && isset( $_GET['x-cache-engine'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only flag for render throttling
3112 $is_infra = true;
3113 }
3114
3115 /**
3116 * Allow a site or host to declare additional preload agents.
3117 *
3118 * @param bool $is_infra Whether this request bypasses the OTTO render throttle.
3119 * @param string $ua The request user-agent.
3120 */
3121 if ( function_exists( 'apply_filters' ) ) {
3122 $is_infra = (bool) apply_filters( 'metasync_otto_unthrottled_agent', $is_infra, $ua );
3123 }
3124
3125 return $is_infra;
3126 }
3127