PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.26
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.26
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 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / otto / otto_pixel.php

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

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