PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.0
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.0
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / otto / otto_pixel.php

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

3,064 lines 138.1 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 # A headless frontend renders the public HTML, so stop after the cheap cache
817 # protections and crawl telemetry, before any OTTO rendering work begins.
818 if (Metasync_Headless_Config::is_active()) {
819 return;
820 }
821
822 # Throttle OTTO rendering for bots that are not exempt: at most one render
823 # per URL+bot every 5 minutes. Humans, search engines (Googlebot, Bingbot,
824 # Yahoo's Slurp, Sogou, Exabot, Applebot, ...), host cache warmers and
825 # synthetic performance auditors are never throttled — their hits always get
826 # full OTTO content, so indexing stays current, the page cache can actually
827 # be filled, and audit tools measure what a real visitor sees. SEO tools,
828 # social crawlers, archivers, AI scrapers, uptime monitors and unrecognized
829 # crawlers are de-duplicated so repeat hits don't re-trigger the expensive
830 # DOM-rewriting path.
831 #
832 # Two complementary gates, deliberately kept separate:
833 # - is_unthrottled_category() consults the classifier's explicit per-bot
834 # category map, so the exempt set is defined in exactly one place.
835 # - metasync_otto_is_unthrottled_infrastructure_agent() matches the raw
836 # user-agent (plus a query marker and a site filter), which catches
837 # preload agents that have no entry in the map at all.
838 if ( $detection['is_bot']
839 && ! Metasync_Otto_Bot_Detector::is_unthrottled_category( $detection['bot_type'] )
840 && ! metasync_otto_is_unthrottled_infrastructure_agent( $detection ) ) {
841 $normalized_url = strtok( $current_url, '?' );
842 if ( $normalized_url === false ) {
843 $normalized_url = $current_url;
844 }
845 // Lowercase + length cap so attacker-controlled bot_name variations
846 // (EvilBota, EvilBotB, EvilBotc, ...) collapse to a bounded key space
847 // and cannot flood wp_options with unique transient rows.
848 $bot_name = substr( strtolower( $detection['bot_name'] ?? 'unknown' ), 0, 32 );
849 $render_throttle_key = 'metasync_otto_rendered_' . md5( $normalized_url . '|' . $bot_name );
850 if ( get_transient( $render_throttle_key ) ) {
851 // Prevent page caches (WP Rocket, W3TC, etc.) from saving the
852 // un-OTTO'd response and serving it to subsequent human visitors.
853 if ( ! defined( 'DONOTCACHEPAGE' ) ) {
854 define( 'DONOTCACHEPAGE', true );
855 }
856 return;
857 }
858 set_transient( $render_throttle_key, 1, 5 * MINUTE_IN_SECONDS );
859 }
860
861 # Skip OTTO on 404s.
862 #
863 # OTTO suggestions are keyed to real, crawled pages. Asking the API about a URL
864 # that returns 404 spends a full API_TIMEOUT of PHP-FPM worker time on a
865 # guaranteed-empty answer. Not theoretical: in one production sample, 21 of 54
866 # crawler requests were 404s on URLs that never existed on the site, each
867 # costing ~2s of a worker.
868 #
869 # Deliberately placed AFTER bot detection so push_crawl_log_to_sa() still
870 # reports the crawl to SearchAtlas — that telemetry is how the crawler side
871 # can learn to stop requesting these URLs. Only the expensive suggestions
872 # lookup is skipped.
873 #
874 # Filterable because "should a 404 page get OTTO treatment" is arguably a site
875 # decision, even though there is no known case where it should.
876 if (is_404() && apply_filters('metasync_otto_skip_on_404', true)) {
877 return;
878 }
879
880 # Optionally skip OTTO processing for bot traffic (when the setting is enabled)
881 if ($bot_detector->should_skip_otto()) {
882 // Log the bot locally and count the saved API call
883 $bot_detector->log_detection($detection);
884 $bot_stats_db = Metasync_Otto_Bot_Statistics_Database::get_instance();
885 $bot_stats_db->increment_api_calls_saved();
886
887 // Skip OTTO processing for this bot
888 return;
889 }
890
891 # OPTIMIZED: Check if Otto should be disabled for WP Rocket compatibility
892 if (class_exists('WP_Rocket')) {
893 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
894
895 if ($wp_rocket_compat_mode === 'disable_otto') {
896 return; # Exit early, Otto is disabled when WP Rocket is active
897 }
898 }
899
900 # Skip OTTO for Divi AJAX pagination and paginated archive requests.
901 # ?et_blog = Divi AJAX pagination callback
902 # /page/N/ = paginated blog/archive pages — OTTO's buffer/HTTP render causes
903 # module numbering mismatch between page 1 (with TB template) and page N
904 # (without TB template), breaking Divi's JS pagination selector matching.
905 if (isset($_GET['et_blog']) || (is_paged() && !is_singular())) {
906 return;
907 }
908
909 # Skip OTTO on search-results pages. A search request carries its
910 # meaning entirely in the query string (e.g. /?s=term, or FiboSearch's
911 # /?s=term&post_type=product&dgwt_wcas=1). get_route() intentionally strips
912 # the query string to build a canonical, cache-key-stable route — which
913 # collapses a query-only search URL to the site root ('/'). OTTO then finds
914 # the HOME page's suggestions for that route and, on the HTTP render path,
915 # serves the home page in place of the search results (the address bar keeps
916 # the search URL — it is a silent content swap, not a redirect). This only
917 # surfaces for logged-in / uncached visitors, since page caches serve the
918 # correct pre-rendered search page to everyone else. Search-results pages
919 # have no per-canonical-URL OTTO suggestions of their own, so OTTO must never
920 # run here. The raw $_GET['s'] check is a defensive fallback for setups where
921 # the main query is altered before this point.
922 if (is_search() || !empty($_GET['s'])) {
923 return;
924 }
925
926 # Handle cache plugin compatibility early - before any caching happens
927 metasync_otto_handle_cache_compatibility();
928
929 # check if OTTO is disabled for this specific page/post
930 # Gate on is_singular() for the same reason the custom/LPS check below does:
931 # on an archive, search or term query get_the_ID() returns the first post of
932 # the loop — WP::register_globals() seeds $GLOBALS['post'] with it — not the
933 # page being rendered. Without the gate, one excluded or per-page-disabled
934 # post silently takes OTTO off every archive that happens to list it first.
935 $post_id = is_singular() ? ( get_queried_object_id() ?: get_the_ID() ) : 0;
936 if ($post_id && class_exists('Metasync_Otto_Frontend_Toolbar')) {
937 if (Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id)) {
938 return;
939 }
940 }
941
942 # Skip OTTO on MetaSync custom HTML / LPS-imported pages — they ship their
943 # own complete, self-contained SEO and OTTO must not inject or overwrite it
944 # with a different/older project's SEO. Resolve the queried object
945 # id (with get_the_ID() fallback) so the static-front-page case — an LPS home
946 # set as the WP front page, where is_page() is false — is still detected.
947 # Applying the skip here, upstream of the single render_route_html() entry,
948 # covers all three render paths (Rocket buffer, output buffer, HTTP fallback).
949 # Only singular views (posts/pages, incl. a static front page) can be a
950 # custom/LPS page; gate on is_singular() so an archive/search/term query
951 # can never have its object id mistaken for a custom page's post id.
952 $custom_page_id = is_singular() ? ( get_queried_object_id() ?: get_the_ID() ) : 0;
953 if (metasync_is_custom_or_lps_page($custom_page_id)) {
954 if (!headers_sent() && Metasync_Otto_Render_Strategy::diagnostics_enabled()) {
955 header('X-MetaSync-OTTO-Method: EXCLUDED');
956 }
957 return;
958 }
959
960 # check if we are having an otto request
961 #
962 # Recognise our own internal fetch by header/User-Agent as well as by the
963 # query parameter. A redirect that rebuilds the URL strips the parameter,
964 # and without this the redirected request re-enters OTTO and fires another
965 # internal fetch - an unbounded self-request loop.
966 # method_exists() is redundant to static analysis - this MR adds the method,
967 # so PHPStan proves the call always true. It is kept for the upgrade window:
968 # during a plugin update an opcache can still hold the previous
969 # Metasync_Otto_Render_Strategy, where class_exists() passes but the method
970 # is absent, and an unguarded static call would fatal the page.
971 # @phpstan-ignore-next-line function.alreadyNarrowedType
972 $otto_has_fetch_detector = class_exists('Metasync_Otto_Render_Strategy') && method_exists('Metasync_Otto_Render_Strategy', 'is_internal_fetch');
973
974 $otto_is_internal_fetch = $otto_has_fetch_detector
975 ? Metasync_Otto_Render_Strategy::is_internal_fetch()
976 : !empty($_GET['is_otto_page_fetch']);
977
978 if($otto_is_internal_fetch){
979
980 # Block SEO plugins NOW for this internal fetch request
981 # metasync_otto_block_seo_plugins();
982 # $_SERVER['REQUEST_URI'] = remove_query_arg('is_otto_page_fetch', $_SERVER['REQUEST_URI']);
983 $block_title = !empty($_GET['otto_block_title']) && $_GET['otto_block_title'] === '1';
984 $block_description = !empty($_GET['otto_block_desc']) && $_GET['otto_block_desc'] === '1';
985
986 # Block SEO plugins conditionally based on what Otto has
987 if ($block_title || $block_description) {
988 metasync_otto_block_seo_plugins($block_title, $block_description);
989 }
990
991 # Remove ALL Otto parameters from REQUEST_URI to prevent them from appearing in pagination, etc.
992 $request_uri_raw = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
993 $_SERVER['REQUEST_URI'] = remove_query_arg(
994 ['is_otto_page_fetch', 'otto_block_title', 'otto_block_desc'],
995 $request_uri_raw
996 );
997
998 # Also remove from $_GET to prevent WordPress from using them
999 unset($_GET['is_otto_page_fetch']);
1000 unset($_GET['otto_block_title']);
1001 unset($_GET['otto_block_desc']);
1002 return;
1003 }
1004
1005 # to avoid unnecessary processese
1006 # OPTIMIZED: check that otto is configured
1007 # And the UUID is properly set before running OTT
1008
1009 # check that we have the option
1010 if(!Metasync_Otto_Config::is_otto_enabled()){
1011 return;
1012 }
1013
1014 # check that otto is enabled
1015 if(!$otto_enabled){
1016 return;
1017 }
1018
1019 # get the otto uuid
1020 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
1021
1022 # start the class
1023 $otto = new Metasync_otto_pixel($otto_uuid);
1024
1025 # call render
1026 $otto->render_route_html();
1027 }
1028
1029 /**
1030 * Handle cache plugin compatibility with Otto
1031 * Controls DONOTCACHEPAGE constant based on active plugins and configuration
1032 * This function is called early in the WordPress lifecycle
1033 */
1034 function metasync_otto_handle_cache_compatibility() {
1035 # Detect active plugins. SG Optimizer detection now lives in
1036 # metasync_otto_disable_sg_page_cache(), which is invoked later
1037 # once OTTO confirms suggestions for the URL.
1038 $brizy_active = class_exists('Brizy_Editor') || defined('BRIZY_VERSION');
1039 $wp_rocket_active = class_exists('WP_Rocket');
1040
1041 # OPTIMIZED: Get configuration option using cached config
1042 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
1043
1044 # Check for Brizy posts in database
1045 global $wpdb;
1046 $has_brizy_posts = false;
1047
1048 if ($brizy_active) {
1049 # OPTIMIZED: Check cache first (1-hour TTL) to avoid querying on every page load
1050 $cached = get_transient('metasync_has_brizy_posts');
1051 if ($cached !== false) {
1052 $has_brizy_posts = ($cached === 'yes');
1053 } else {
1054 # Query database only if cache missed
1055 $has_brizy_posts = $wpdb->get_var(
1056 "SELECT COUNT(*) FROM {$wpdb->postmeta}
1057 WHERE meta_key = 'brizy_post_uid'
1058 AND meta_value != ''
1059 LIMIT 1"
1060 );
1061
1062 # Cache result for 1 hour
1063 $result = !empty($has_brizy_posts) ? 'yes' : 'no';
1064 set_transient('metasync_has_brizy_posts', $result, HOUR_IN_SECONDS);
1065 }
1066 }
1067
1068 # Determine if DONOTCACHEPAGE should be set
1069 $should_set_donotcachepage = false;
1070
1071 # Case 1: Brizy is active with posts - always needed. Brizy pages are
1072 # dynamically rendered regardless of OTTO, so caching must be disabled
1073 # whenever Brizy posts exist.
1074 if ($brizy_active && !empty($has_brizy_posts)) {
1075 $should_set_donotcachepage = true;
1076 }
1077
1078 # Case 2: User explicitly disabled Otto for WP Rocket compatibility
1079 elseif ($wp_rocket_active && $wp_rocket_compat_mode === 'disable_otto') {
1080 $should_set_donotcachepage = true;
1081 return; # Exit early, Otto won't run
1082 }
1083
1084 # Case 3: WP Rocket active with auto/buffer mode - DON'T set DONOTCACHEPAGE
1085 # This allows WP Rocket optimizations to continue working
1086
1087 # NOTE: SiteGround SG Optimizer cache bypass is intentionally NOT
1088 # handled here. Emitting no-cache headers / disabling SG cache on this hook
1089 # fired on EVERY front-end page — including pages OTTO never modifies —
1090 # forcing SG to bypass its page cache site-wide and spiking CPU. The SG
1091 # bypass is now deferred to metasync_otto_disable_sg_page_cache(), called
1092 # from render_route_html() only after OTTO confirms it has suggestions for
1093 # the current URL.
1094
1095 # Only set DONOTCACHEPAGE if needed
1096 if ($should_set_donotcachepage && !defined('DONOTCACHEPAGE')) {
1097 define('DONOTCACHEPAGE', true);
1098 }
1099 }
1100
1101 /**
1102 * Disable SiteGround SG Optimizer page caching for the CURRENT request only.
1103 *
1104 * Called from Metasync_otto_pixel::render_route_html() once OTTO has confirmed
1105 * it has suggestions to apply to the requested URL. Scoping the no-cache
1106 * override to pages OTTO actually modifies — rather than emitting it on every
1107 * front-end page via the unconditional `wp` hook — keeps SG's page cache
1108 * working site-wide and avoids the CPU spike reported in.
1109 *
1110 * Only meaningful on SiteGround sites without WP Rocket; a no-op otherwise.
1111 */
1112 function metasync_otto_disable_sg_page_cache() {
1113 # Only relevant when SG Optimizer is active and WP Rocket is not present.
1114 if (class_exists('WP_Rocket')) {
1115 return;
1116 }
1117 if (!is_plugin_active('sg-cachepress/sg-cachepress.php')) {
1118 return;
1119 }
1120
1121 if (!defined('DONOTCACHEPAGE')) {
1122 define('DONOTCACHEPAGE', true);
1123 }
1124 if (!defined('SG_CachePress_SUPERCACHER')) {
1125 define('SG_CachePress_SUPERCACHER', false);
1126 }
1127
1128 add_filter('sgo_html_cache_disable', '__return_true', 999);
1129 add_filter('sgo_css_combine_exclude', '__return_true', 999);
1130 add_filter('sgo_js_combine_exclude', '__return_true', 999);
1131 add_filter('sgo_cache_this_page', '__return_false', 999);
1132
1133 if (!headers_sent()) {
1134 header('Cache-Control: no-cache, must-revalidate, max-age=0');
1135 header('X-Accel-Expires: 0');
1136 }
1137 }
1138
1139 /**
1140 * How long a response that OTTO could not optimize may be cached, in seconds.
1141 *
1142 * Deliberately short rather than zero — see
1143 * metasync_otto_limit_response_cache() for why.
1144 */
1145 if (!defined('METASYNC_OTTO_FALLBACK_CACHE_TTL')) {
1146 define('METASYNC_OTTO_FALLBACK_CACHE_TTL', 60);
1147 }
1148
1149 /**
1150 * Cap how long the current response may be cached, because OTTO could not apply
1151 * its suggestions to it.
1152 *
1153 * OTTO fails open: when suggestions are unavailable, or when the HTML rewrite
1154 * fails, the site's own un-optimized markup is served instead. That markup is
1155 * valid — the harm is that a caching layer then stores it and serves it to
1156 * everyone for the rest of its TTL (a host edge cache with s-maxage=86400 pins
1157 * it for a day), which surfaces to customers as the OTTO title "reverting".
1158 *
1159 * A short TTL is used rather than `no-store`, which is the obvious choice but the
1160 * wrong one. Some failures look transient and are not: a page that always breaks
1161 * the rewriter, or a host that cannot fetch its own pages, fails identically on
1162 * every request. `no-store` would make those permanently uncacheable, sending
1163 * 100% of their traffic to the origin forever in exchange for markup that will
1164 * never improve. A one-minute ceiling fixes the reported bug just as completely —
1165 * a day-long pin becomes a minute — while capping the cost of being wrong about
1166 * which failures recover.
1167 *
1168 * Different layers read different signals, so all of them are emitted:
1169 *
1170 * - Cache-Control CDNs, host edge caches, browsers. `max-age=0` keeps
1171 * browsers revalidating while `s-maxage` lets shared
1172 * caches absorb a burst for the ceiling's duration.
1173 * - X-Accel-Expires Nginx's own directive. Evaluated with higher
1174 * precedence than Cache-Control, and not covered by
1175 * the `fastcgi_ignore_headers Cache-Control Expires`
1176 * recipe common on managed hosts — so it can survive
1177 * a config that discards Cache-Control. Nginx strips
1178 * X-Accel-* before the response reaches the client,
1179 * so there is no visitor-facing side effect.
1180 * - Surrogate-Control Varnish and Fastly. Note that on stock Varnish the
1181 * mere presence of this header suppresses its
1182 * Cache-Control check, so it must carry a real
1183 * lifetime rather than be left at some other value.
1184 * - DONOTCACHEPAGE WordPress-level page caches (WP Rocket, W3TC,
1185 * LiteSpeed, SG Optimizer). These run inside PHP and
1186 * never see response headers. They have no notion of a
1187 * short TTL — it is cache or do not — so they skip
1188 * entirely. That is safe here because callers only
1189 * invoke this for failures expected to resolve; a
1190 * permanent one (an oversized page, a host that cannot
1191 * reach itself) is left fully cacheable by the caller
1192 * and never reaches this function.
1193 *
1194 * Cache-tagging headers (Surrogate-Key, Cache-Tag, Edge-Cache-Tag) are
1195 * deliberately left in place. They are purge-targeting labels, not cacheability
1196 * directives — no CDN stores a response *because* a tag is present. On a layer
1197 * that stores this response anyway, those tags are the only handle a later purge
1198 * has on it, so removing them would discard the recovery path exactly where it
1199 * is needed most.
1200 *
1201 * Some layers may honour none of this; a managed host that overrides origin
1202 * cache headers will cache the response regardless. This bounds the damage where
1203 * it can and is inert where it cannot.
1204 *
1205 * @param string $reason Short machine-readable reason, surfaced as a response
1206 * header for debugging (e.g. 'API_ERROR', 'RENDER_FAILED').
1207 */
1208 /**
1209 * Build the response headers that cap how long an un-optimized response may be
1210 * cached.
1211 *
1212 * Thin wrapper over Metasync_Otto_Render_Strategy::fallback_cache_headers(), which
1213 * owns the header set so it can be unit-tested (this file cannot be loaded in a
1214 * test context). Returns an empty array if the strategy class is somehow absent,
1215 * so a caller emits nothing rather than an untested divergent copy.
1216 *
1217 * @param int $ttl Cache ceiling in seconds.
1218 * @param string $reason Optional machine-readable reason for the debug header.
1219 * @return array Header name => value.
1220 */
1221 function metasync_otto_fallback_cache_headers($ttl, $reason = '') {
1222 if (!class_exists('Metasync_Otto_Render_Strategy')) {
1223 return [];
1224 }
1225
1226 return Metasync_Otto_Render_Strategy::fallback_cache_headers_for_response($ttl, $reason);
1227 }
1228
1229 function metasync_otto_limit_response_cache($reason = '') {
1230 # This function must only ever make the response LESS cacheable. header()
1231 # replaces by default and the capped set carries X-Accel-Expires, which nginx
1232 # evaluates ahead of Cache-Control — so emitting it unconditionally could
1233 # override an upstream no-store and grant edge caching that nothing had
1234 # granted. The strategy reconciles existing cache directives before emitting
1235 # a capped set.
1236
1237 $strategy_available = class_exists('Metasync_Otto_Render_Strategy');
1238
1239 # 1. Logged-in users. OTTO renders for them unless the "disable on logged in"
1240 # setting is switched on, which is off by default — so this is the common
1241 # case, not an edge case. Their page may carry session-specific or
1242 # membership-gated content, and capping at 60 seconds would still let a
1243 # shared cache store and re-serve it to somebody else.
1244 if (function_exists('is_user_logged_in') && is_user_logged_in()) {
1245 if (!headers_sent() && $strategy_available) {
1246 foreach (Metasync_Otto_Render_Strategy::no_store_headers($reason) as $name => $value) {
1247 header($name . ': ' . $value);
1248 }
1249 }
1250 if (!defined('DONOTCACHEPAGE')) {
1251 define('DONOTCACHEPAGE', true);
1252 }
1253 return;
1254 }
1255
1256 # 2. Something upstream already restricted this response. Leave its headers
1257 # alone — a page deliberately marked no-store must not be handed a 60
1258 # second shared-cache lifetime by us.
1259 # The strategy turns an existing no-store into a consistent no-store set and
1260 # uses the strictest known existing lifetime for every cache layer.
1261 $ttl = (int) METASYNC_OTTO_FALLBACK_CACHE_TTL;
1262
1263 if (!headers_sent()) {
1264 foreach (metasync_otto_fallback_cache_headers($ttl, $reason) as $name => $value) {
1265 header($name . ': ' . $value);
1266 }
1267 }
1268
1269 # Reaches page-cache plugins, which do not see the headers above.
1270 if (!defined('DONOTCACHEPAGE')) {
1271 define('DONOTCACHEPAGE', true);
1272 }
1273 }
1274
1275 /**
1276 * Report an OTTO render failure to the local error log and to Sentry.
1277 *
1278 * These failures were invisible: the render path swallowed them and returned the
1279 * original HTML, so nobody knew how often they fired. Reporting them is what makes
1280 * a persistently-failing URL discoverable instead of silently costing origin work.
1281 *
1282 * Two constraints shape this:
1283 *
1284 * 1. It runs on the visitor render path, so it must not add latency. The Sentry
1285 * report is therefore queued here and sent by
1286 * metasync_otto_flush_render_failure_reports() after the response has been
1287 * handed to the visitor, so the cost is invisible rather than merely small.
1288 *
1289 * Two alternatives were tried and rejected. Sending inline with a blocking
1290 * transport — cURL with a 5s connect and 5s read timeout — could add ten
1291 * seconds to a page load, precisely when the request is already degraded.
1292 * Sending inline non-blocking is far cheaper but still not free: opening the
1293 * connection and writing the body costs the visitor something.
1294 *
1295 * A WP-Cron single event was the other obvious option and is worse than both:
1296 * every scheduled event is stored in the `cron` option, which is autoloaded on
1297 * every request site-wide, and WP's own 10-minute duplicate suppression keys on
1298 * the serialized args — which carry per-request byte counts here, so it would
1299 * never engage. A site with many failing URLs would grow that option
1300 * unboundedly and pay for it on every page load, which is the cost the deferral
1301 * was meant to avoid in the first place.
1302 *
1303 * The local log stays synchronous. It is cheaper than Sentry but not free —
1304 * Metasync_Error_Logger::log() rewrites a summary option on each call — which is
1305 * the second reason for the throttle below.
1306 *
1307 * 2. A failing page can be requested thousands of times an hour, so reports are
1308 * throttled per reason+URL.
1309 *
1310 * @param string $reason Machine-readable failure reason (e.g. 'RENDER_EXCEPTION').
1311 * @param string $message Human-readable description.
1312 * @param array $context Extra key/value context.
1313 * @param string $level Sentry level: 'error' or 'warning'.
1314 */
1315 function metasync_otto_report_render_failure($reason, $message, $context = [], $level = 'error') {
1316 $url = '';
1317 if (!empty($_SERVER['HTTP_HOST']) && !empty($_SERVER['REQUEST_URI'])) {
1318 $url = (is_ssl() ? 'https://' : 'http://')
1319 . $_SERVER['HTTP_HOST']
1320 . strtok(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), '?');
1321 }
1322
1323 # Throttle: at most one report per reason+URL per 5 minutes. Requests without a
1324 # resolvable URL (CLI, odd SAPIs) collapse onto one key, which is intended —
1325 # they cannot be attributed to a page anyway.
1326 $throttle_key = 'metasync_otto_fail_' . md5($reason . '|' . $url);
1327 if (get_transient($throttle_key)) {
1328 return;
1329 }
1330 set_transient($throttle_key, 1, 5 * MINUTE_IN_SECONDS);
1331
1332 $context = array_merge([
1333 'reason' => $reason,
1334 'url' => $url,
1335 ], $context);
1336
1337 # Local structured log. Stays on the customer's site, so it is useful to
1338 # support but not visible to us — hence the Sentry dispatch below.
1339 if (class_exists('Metasync_Error_Logger')) {
1340 Metasync_Error_Logger::log(
1341 Metasync_Error_Logger::CATEGORY_OTTO_RENDER,
1342 $level === 'warning'
1343 ? Metasync_Error_Logger::SEVERITY_WARNING
1344 : Metasync_Error_Logger::SEVERITY_ERROR,
1345 $message,
1346 $context
1347 );
1348 }
1349
1350 # Sentry. Queued rather than sent here: even a non-blocking dispatch costs the
1351 # visitor something (opening the connection and writing the body), and this runs
1352 # on a request that is already degraded. The queue is flushed after the response
1353 # has been handed to the visitor — see metasync_otto_flush_render_failure_reports().
1354 if (!function_exists('metasync_sentry_capture_message_nonblocking')) {
1355 return;
1356 }
1357
1358 $GLOBALS['metasync_otto_pending_failure_reports'][] = [
1359 'message' => $message,
1360 'level' => $level,
1361 'context' => $context,
1362 ];
1363
1364 if (empty($GLOBALS['metasync_otto_failure_flush_hooked'])) {
1365 $GLOBALS['metasync_otto_failure_flush_hooked'] = true;
1366 # PHP_INT_MAX so this runs after everything else registered on shutdown —
1367 # WordPress flushes its own output buffers and closes the object cache at the
1368 # default priority, and this handler ends output for the request.
1369 add_action('shutdown', 'metasync_otto_flush_render_failure_reports', PHP_INT_MAX);
1370 }
1371 }
1372
1373 /**
1374 * Send any queued render-failure reports after the response has been delivered.
1375 *
1376 * fastcgi_finish_request() (PHP-FPM) and litespeed_finish_request() (LiteSpeed)
1377 * flush the response and close the connection to the visitor while leaving PHP
1378 * running. Calling one of them here means the page is already on its way before
1379 * any telemetry work starts, so the reporting cost is invisible to the visitor
1380 * rather than merely small.
1381 *
1382 * Neither exists under mod_php or the CLI SAPI. There the reports are still sent
1383 * at the very end of the request, after all page output has been generated and
1384 * flushed — the same ordering, just without the connection being closed first.
1385 *
1386 * The dispatch itself stays non-blocking. Now that the visitor is served, the only
1387 * remaining cost is PHP worker occupancy, and a blocking transport with a 5s
1388 * connect and 5s read timeout could tie up a worker for ten seconds per report on
1389 * a site that is failing often.
1390 */
1391 function metasync_otto_flush_render_failure_reports() {
1392 $reports = $GLOBALS['metasync_otto_pending_failure_reports'] ?? [];
1393 $GLOBALS['metasync_otto_pending_failure_reports'] = [];
1394
1395 if (empty($reports)) {
1396 return;
1397 }
1398
1399 if (function_exists('fastcgi_finish_request')) {
1400 fastcgi_finish_request();
1401 } elseif (function_exists('litespeed_finish_request')) {
1402 litespeed_finish_request();
1403 }
1404
1405 if (!function_exists('metasync_sentry_capture_message_nonblocking')) {
1406 return;
1407 }
1408
1409 foreach ($reports as $report) {
1410 try {
1411 metasync_sentry_capture_message_nonblocking(
1412 $report['message'],
1413 $report['level'],
1414 $report['context']
1415 );
1416 } catch (Exception $e) {
1417 # Telemetry must never be able to break a request.
1418 } catch (Error $e) {
1419 # Same.
1420 }
1421 }
1422 }
1423
1424 /**
1425 * Whether MetaSync is still emitting Open Graph / Twitter tags.
1426 *
1427 * The Social Media & Open Graph switch stops MetaSync writing those tags on
1428 * every render path, so the suppression below has to stand down with it —
1429 * removing another plugin's og:/twitter: tag while contributing none of our own
1430 * would leave the page with no social tags at all.
1431 *
1432 * The class_exists() guard keeps this usable if the flags file has not loaded
1433 * yet; treating that window as "enabled" preserves the previous behaviour.
1434 *
1435 * @return bool
1436 */
1437 function metasync_otto_social_output_enabled() {
1438 if (!class_exists('Metasync_Feature_Flags')) {
1439 return true;
1440 }
1441
1442 return Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::SOCIAL_OG);
1443 }
1444
1445 /**
1446 * Block SEO plugins conditionally based on what Otto is providing
1447 * Only blocks title if Otto has title, only blocks description if Otto has description
1448 * This prevents duplicate SEO tags while allowing fallback to SEO plugins when Otto has no data.
1449 * Supports Yoast SEO, Rank Math, and AIOSEO (free + pro).
1450 *
1451 * @param bool $block_title Whether to block title tags.
1452 * @param bool $block_description Whether to block description tags.
1453 * @param array $description_tags Optional. Granular list of OTTO-provided description tags
1454 * (e.g. ['meta[name=description]', 'meta[property=og:description]']).
1455 * When provided, only matching AIOSEO tags are suppressed.
1456 * When empty, all AIOSEO description tags are suppressed (legacy behavior).
1457 */
1458 function metasync_otto_block_seo_plugins($block_title = false, $block_description = false, $description_tags = []) {
1459 # Disable Yoast SEO (free and premium)
1460 if (is_plugin_active('wordpress-seo/wp-seo.php') ||
1461 is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {
1462
1463 # TITLE: Never block Yoast's title output during SSR fetch.
1464 # Yoast removes WordPress's native _wp_render_title_tag action and is the sole
1465 # renderer of the <title> tag. Returning false/empty from wpseo_title or removing
1466 # Title_Presenter leaves the page with NO <title> tag at all — OTTO's buffer
1467 # post-processing then has nothing to replace, producing a missing title.
1468 # Instead, let Yoast render its own title; OTTO's replace_title() will overwrite
1469 # it in the final HTML buffer. deduplicate_title_tags() cleans up any duplicates.
1470
1471 # Block description only if Otto has description
1472 if ($block_description) {
1473 add_filter('wpseo_metadesc', '__return_false', 999);
1474 add_filter('wpseo_meta_description', '__return_false', 999);
1475 add_filter('wpseo_metakeywords', '__return_false', 999);
1476 }
1477
1478 # Block Yoast's modern presenters — description only, never title
1479 add_filter('wpseo_frontend_presenters', function($presenters) use ($block_description) {
1480 if (!is_array($presenters)) return $presenters;
1481
1482 $presenters_to_remove = [];
1483
1484
1485 # Remove description presenters only when OTTO has a description
1486 if ($block_description) {
1487 # The plain meta description belongs to the SEO title/description
1488 # feature, which has its own setting.
1489 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Meta_Description_Presenter';
1490
1491 # The OG/Twitter descriptions are social tags. With that feature
1492 # switched off MetaSync writes none of its own, so Yoast's must be
1493 # left alone rather than removed with nothing to replace them.
1494 if (metasync_otto_social_output_enabled()) {
1495 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Open_Graph\Description_Presenter';
1496 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Twitter\Description_Presenter';
1497 }
1498 }
1499
1500 foreach ($presenters as $key => $presenter) {
1501 // Safely get class name, suppressing autoload errors
1502 // This prevents warnings when Composer autoloader tries to load deprecated Yoast files
1503 $class_name = is_object($presenter) ? @get_class($presenter) : '';
1504
1505 if (!empty($class_name) && in_array($class_name, $presenters_to_remove)) {
1506 unset($presenters[$key]);
1507 }
1508 }
1509 return $presenters;
1510 }, 999);
1511 }
1512
1513 # Disable Rank Math
1514 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
1515 is_plugin_active('seo-by-rankmath/rank-math.php')) {
1516
1517 # TITLE: Never blank Rank Math's title output during SSR fetch.
1518 # rank_math/frontend/title also gates Rank Math's og:title/twitter:title
1519 # output, so forcing it empty blanks those social tags along with the
1520 # document title. Let Rank Math render its own title/og/twitter tags;
1521 # OTTO's replace_title() and the dedup/precedence pass clean up duplicates.
1522
1523 if ($block_description) {
1524 add_filter('rank_math/frontend/description', '__return_false', 999);
1525 add_filter('rank_math/frontend/show_keywords', '__return_false', 999);
1526 }
1527 }
1528
1529 # Disable AIOSEO (free and pro)
1530 if (is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
1531 is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
1532
1533 if ($block_title) {
1534 # The document title belongs to the SEO title feature, which has its
1535 # own setting; only the og:/twitter: unsets below are social.
1536 add_filter('aioseo_title', '__return_empty_string', 999);
1537
1538 if (metasync_otto_social_output_enabled()) {
1539 add_filter('aioseo_facebook_tags', function($meta) {
1540 if (is_array($meta)) { unset($meta['og:title']); }
1541 return $meta;
1542 }, 999);
1543 add_filter('aioseo_twitter_tags', function($meta) {
1544 if (is_array($meta)) { unset($meta['twitter:title']); }
1545 return $meta;
1546 }, 999);
1547 }
1548 }
1549
1550 if ($block_description) {
1551 # Use granular tag list when available to only block what OTTO provides
1552 $tags = !empty($description_tags) ? $description_tags : [];
1553 $block_standard = empty($tags) || in_array('meta[name=description]', $tags);
1554 # Social tags only get blocked while MetaSync is still emitting them.
1555 $social_enabled = metasync_otto_social_output_enabled();
1556 $block_og_desc = $social_enabled && (empty($tags) || in_array('meta[property=og:description]', $tags));
1557 $block_tw_desc = $social_enabled && (empty($tags) || in_array('meta[name=twitter:description]', $tags));
1558
1559 if ($block_standard) {
1560 add_filter('aioseo_description', '__return_empty_string', 999);
1561 }
1562 if ($block_og_desc) {
1563 add_filter('aioseo_facebook_tags', function($meta) {
1564 if (is_array($meta)) { unset($meta['og:description']); }
1565 return $meta;
1566 }, 999);
1567 }
1568 if ($block_tw_desc) {
1569 add_filter('aioseo_twitter_tags', function($meta) {
1570 if (is_array($meta)) { unset($meta['twitter:description']); }
1571 return $meta;
1572 }, 999);
1573 }
1574 }
1575 }
1576 }
1577 # Check whether OTTO is ALSO injected via JavaScript on the site (a misconfiguration
1578 # we warn admins about).
1579 #
1580 # The actual detection makes a loopback HTTP request to the site's OWN url. On hosts
1581 # that disallow same-server loopback (e.g. SiteGround), that request blocks for the
1582 # full timeout and then fails — and when it runs inline on admin_notices it makes
1583 # every wp-admin page hang. It must therefore NEVER run inline on an admin request.
1584 #
1585 # metasync_check_otto_js() is now read-only: it returns the cached result and, on a
1586 # cache miss, schedules a one-off BACKGROUND job (WP-Cron) to compute it. The notice
1587 # simply shows nothing until the background result is available.
1588 function metasync_check_otto_js(){
1589
1590 $cache_key = 'metasync_otto_js_detected';
1591 $cached = get_transient($cache_key);
1592
1593 if ($cached !== false) {
1594 return $cached === 'yes';
1595 }
1596
1597 # No cached result yet — compute it off the request so we never block admin
1598 # with a loopback call. Show nothing this load.
1599 metasync_schedule_otto_js_check();
1600 return false;
1601 };
1602
1603 # Queue the background loopback detection if it isn't already scheduled.
1604 function metasync_schedule_otto_js_check(){
1605 if (!wp_next_scheduled('metasync_otto_js_check_event')) {
1606 wp_schedule_single_event(time() + 5, 'metasync_otto_js_check_event');
1607 }
1608 }
1609
1610 # Background worker (runs in WP-Cron context, NOT on the admin request): performs the
1611 # blocking loopback request and caches the result. Hooked to metasync_otto_js_check_event.
1612 function metasync_run_otto_js_check(){
1613 $cache_key = 'metasync_otto_js_detected';
1614
1615 # the site url with the internal-fetch marker so OTTO does not re-process it
1616 $site_url = site_url() . '?is_otto_page_fetch=1';
1617
1618 $page_data = wp_remote_get($site_url, array('timeout' => 5, 'sslverify' => false));
1619
1620 if (is_wp_error($page_data)) {
1621 # Loopback failed (host likely blocks same-server requests). Cache "no" so we
1622 # don't keep re-queuing the check on every admin page load.
1623 set_transient($cache_key, 'no', HOUR_IN_SECONDS);
1624 return;
1625 }
1626
1627 $body = wp_remote_retrieve_body($page_data);
1628
1629 # Detect the OTTO script via a lightweight regex instead of parsing
1630 # the entire fetched page into a SimpleHtmlDom tree (which exhausted memory
1631 # on large pages — this check runs on admin page loads). We only need to
1632 # confirm a <script id="sa-dynamic-optimization" ... data-uuid="..."> exists.
1633 $found = preg_match(
1634 '/<script\b[^>]*\bid=["\']sa-dynamic-optimization["\'][^>]*\bdata-uuid=["\'][^"\']+["\']/i',
1635 (string) $body
1636 )
1637 || preg_match(
1638 # Attribute order may vary (data-uuid before id)
1639 '/<script\b[^>]*\bdata-uuid=["\'][^"\']+["\'][^>]*\bid=["\']sa-dynamic-optimization["\']/i',
1640 (string) $body
1641 );
1642
1643 if ($found) {
1644 set_transient($cache_key, 'yes', 12 * HOUR_IN_SECONDS);
1645 return;
1646 }
1647
1648 set_transient($cache_key, 'no', 12 * HOUR_IN_SECONDS);
1649 }
1650
1651 # Register the background worker hook.
1652 add_action('metasync_otto_js_check_event', 'metasync_run_otto_js_check');
1653
1654 # Handle AJAX Clear Cache request
1655 # NOTE: Cache system removed - this is now a no-op
1656 function metasync_clear_otto_cache_handler() {
1657 if (!empty($_GET['clear_otto_cache'])) {
1658 delete_transient('metasync_otto_js_detected');
1659 # Re-run the JS detection in the background so the notice refreshes.
1660 metasync_schedule_otto_js_check();
1661 # Cache system has been removed - no cache to clear
1662 wp_send_json_success(['message' => 'Cache system removed - all pages processed in real-time']);
1663 }
1664 else {
1665 wp_send_json_error(['message' => 'Missing parameter']);
1666 }
1667 }
1668
1669 # Clear cache hook
1670 add_action('wp_ajax_metasync_clear_otto_cache', 'metasync_clear_otto_cache_handler');
1671
1672 # add admin action to check script
1673 function metasync_show_otto_ssr_notice() {
1674 if (!Metasync::current_user_has_plugin_access()) {
1675 return; // Only show to admins
1676 }
1677
1678 # Get the plugin name using centralized method
1679 $plugin_name = Metasync::get_effective_plugin_name();
1680 $whitelabel_otto_name = Metasync::get_whitelabel_otto_name();
1681 if (metasync_check_otto_js()) {
1682
1683 # Show admin notice with plugin name included in the message
1684 echo '<div class="notice notice-error">
1685 <p><b>Warning from ' . esc_html($plugin_name) . '</b>
1686 <br>
1687 ' . 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
1688 </p>
1689 </div>';
1690 }
1691 }
1692
1693 add_action('admin_notices', 'metasync_show_otto_ssr_notice');
1694
1695
1696 # staging dummy change
1697 # load otto in the wp hook
1698 add_action('wp', 'metasync_start_otto');
1699
1700 # ENHANCED OTTO SEO INTEGRATION
1701 # Register async SEO processing hook
1702 add_action('metasync_process_seo_job', 'metasync_process_otto_seo_data', 10, 4);
1703 add_action('metasync_process_otto_crawl_url_job', 'metasync_handle_otto_crawl_url_job', 10, 2);
1704 add_action('metasync_process_otto_batch_cache_job', 'metasync_handle_otto_batch_cache_job', 10, 2);
1705 add_action('metasync_otto_pending_drainer', 'metasync_handle_otto_pending_drainer');
1706
1707 # Process OTTO SEO data and update WordPress meta fields for SEO plugins
1708 # This function now runs asynchronously via WordPress cron system
1709 #
1710 # Returns one of the Metasync_Otto_Job_Status::OUTCOME_* strings so callers
1711 # can distinguish success, confirmed no-change, retryable failure, permanent
1712 # failure, and CPU deferral — a bare boolean cannot drive correct retry and
1713 # purge decisions.
1714 #
1715 # @param string $route Fully-qualified URL to process.
1716 # @param bool $allow_defer When false, skip CPU-deferral rescheduling (used when
1717 # called synchronously from crawl_url_job whose own retry
1718 # mechanism already handles failures).
1719 # @param int $deferral_count How many times this job has already been deferred for
1720 # CPU load. Prevents infinite reschedule loops.
1721 # @param array|null $prefetched_seo_data Suggestions already fetched by the transient
1722 # warm step. When an array it is used as-is so the job
1723 # makes exactly ONE API call per URL; the endpoint is
1724 # only fetched here when no prefetch was provided.
1725
1726 function metasync_process_otto_seo_data($route, $allow_defer = true, $deferral_count = 0, $prefetched_seo_data = null) {
1727 # Maximum number of times a job can be deferred before it is dropped.
1728 $max_deferrals = defined('METASYNC_SEO_JOB_MAX_DEFERRALS') ? METASYNC_SEO_JOB_MAX_DEFERRALS : 5;
1729 $lock_key = null;
1730 $lock_acquired = false;
1731
1732 try {
1733 # Validate input
1734 if (empty($route) || !is_string($route)) {
1735 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1736 }
1737
1738 # CPU load check — defer if server is under load.
1739 # Only reschedule when called from the cron hook (allow_defer=true) and
1740 # we haven't exceeded the maximum deferral count.
1741 # Checked BEFORE acquiring the lock so that deferred jobs don't
1742 # acquire-then-immediately-release it (which defeats concurrency protection).
1743 if (!Metasync_CPU_Monitor::is_load_safe()) {
1744 if ($allow_defer) {
1745 if ($deferral_count < $max_deferrals) {
1746 metasync_otto_schedule_single_event(
1747 time() + 60,
1748 'metasync_process_seo_job',
1749 array($route, true, $deferral_count + 1)
1750 );
1751 return Metasync_Otto_Job_Status::OUTCOME_DEFERRED;
1752 }
1753 # deferral budget exhausted — RUN the job late instead
1754 # of dropping it. The write is idempotent and bounded, and a
1755 # late sync is strictly better than SEO data that never
1756 # arrives. No new cron events are created, so the
1757 # anti-pile-up guarantee is preserved. Fall through.
1758 } else {
1759 # allow_defer=false (sync path): report retryable without creating
1760 # cron events — the crawl-url job's retry mechanism (or OTTO's
1761 # next crawl) re-triggers this URL.
1762 return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
1763 }
1764 }
1765
1766 # Concurrency lock: prevent multiple SEO jobs from running simultaneously.
1767 # Uses a per-URL transient lock with a 120s TTL as a safety net (the lock is
1768 # explicitly deleted on completion). If the lock exists another run is already
1769 # processing this URL — reschedule once with a short delay instead of stacking.
1770 $lock_key = 'metasync_seo_lock_' . md5($route);
1771 if (get_transient($lock_key) !== false) {
1772 if ($allow_defer && $deferral_count < $max_deferrals) {
1773 metasync_otto_schedule_single_event(
1774 time() + 30,
1775 'metasync_process_seo_job',
1776 array($route, true, $deferral_count + 1)
1777 );
1778 }
1779 return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
1780 }
1781 set_transient($lock_key, true, 120);
1782 $lock_acquired = true;
1783
1784 # Resolve redirect table: use final destination URL before 404 checks and OTTO processing
1785 $route = metasync_otto_resolve_redirect_to_final_url($route);
1786
1787 # Skip excluded URLs - don't process SEO data for them
1788 if (metasync_is_otto_url_excluded($route)) {
1789 //error_log('MetaSync OTTO: Skipping SEO processing for excluded URL: ' . $route);
1790 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1791 }
1792
1793 # Pre-flight 404 check: exclude URLs that would return 404 before making API call
1794 if (!metasync_otto_is_url_available($route)) {
1795 error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route}");
1796 metasync_otto_auto_exclude_404_url($route);
1797 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1798 }
1799
1800 # Get OTTO UUID from settings
1801 # OPTIMIZED: Use cached options
1802 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
1803
1804 if (empty($otto_uuid)) {
1805 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1806 }
1807
1808 # Meta descriptions are always enabled by default - no check needed
1809
1810 # Obtain the OTTO suggestions for this URL. When the caller already
1811 # warmed the transient cache (crawl-url job), those suggestions are
1812 # handed in and NO second API call is made — the endpoint, rate
1813 # limiter, and breaker are only hit once per URL.
1814 # An empty array is a legitimate answer ("OTTO holds nothing for this
1815 # URL") and must not be treated as a fetch failure; only a strict
1816 # false from the fetcher is a failure.
1817 if (is_array($prefetched_seo_data)) {
1818 $seo_data = $prefetched_seo_data;
1819 } else {
1820 $failure_class = null;
1821 $seo_data = metasync_fetch_otto_seo_data($route, $otto_uuid, $failure_class);
1822 if ($seo_data === false) {
1823 if ($failure_class === Metasync_Otto_Job_Status::OUTCOME_PERMANENT) {
1824 # Auth/input-level rejection from the API — retrying the
1825 # same request cannot help.
1826 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1827 }
1828 # Timeout / 5xx / 429 / empty body / bad JSON: retryable.
1829 metasync_record_failed_action( 'metasync_process_seo_job' );
1830 return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
1831 }
1832 }
1833
1834 # Mark this URL as crawled by OTTO for SSR
1835 # Extract domain and path from route
1836 $parsed_url = parse_url($route);
1837 $domain_with_scheme = ($parsed_url['scheme'] ?? 'https') . '://' . ($parsed_url['host'] ?? '');
1838 $url_path = ($parsed_url['path'] ?? '/');
1839
1840 # Create crawl data structure
1841 $crawl_data = array(
1842 'domain' => $domain_with_scheme,
1843 'urls' => array($url_path)
1844 );
1845
1846 # Load Otto pixel class and save crawl data
1847 $otto_pixel = new Metasync_otto_pixel($otto_uuid);
1848 $otto_pixel->save_crawl_data($crawl_data);
1849
1850 # Get WordPress post ID from URL
1851 $post_id = url_to_postid($route);
1852
1853 # Special handling for WooCommerce shop page (url_to_postid doesn't work for it)
1854 if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) {
1855 # Check if this URL is the WooCommerce shop page
1856 $shop_page_id = wc_get_page_id('shop');
1857 if ($shop_page_id > 0) {
1858 $shop_url = get_permalink($shop_page_id);
1859 $route_normalized = rtrim($route, '/');
1860 $shop_url_normalized = rtrim($shop_url, '/');
1861
1862 if ($route_normalized === $shop_url_normalized) {
1863 $post_id = $shop_page_id;
1864 }
1865 }
1866 }
1867
1868 # Try to find WooCommerce product by URL if url_to_postid failed
1869 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
1870 # Extract product slug from URL
1871 $product_slug = basename(parse_url($route, PHP_URL_PATH));
1872
1873 # Try to get product by slug
1874 $products = wc_get_products(array(
1875 'name' => $product_slug,
1876 'limit' => 1,
1877 'status' => 'publish',
1878 ));
1879
1880 if (empty($products)) {
1881 # Fallback: try by slug using WP_Query
1882 $args = array(
1883 'post_type' => 'product',
1884 'name' => $product_slug,
1885 'posts_per_page' => 1,
1886 'post_status' => 'publish',
1887 );
1888 $query = new WP_Query($args);
1889
1890 if ($query->have_posts()) {
1891 $product_post = $query->posts[0];
1892 $post_id = $product_post->ID;
1893 }
1894 } else {
1895 $product = $products[0];
1896 $post_id = $product->get_id();
1897 }
1898 }
1899
1900 if (!$post_id || $post_id <= 0) {
1901 # Check if this is a category page
1902 if (strpos($route, '/category/') !== false) {
1903 # Extract category slug from URL
1904 $category_slug = basename(parse_url($route, PHP_URL_PATH));
1905 $category = get_category_by_slug($category_slug);
1906
1907 if ($category) {
1908 # Check if category would return 404 before applying OTTO changes
1909 if (metasync_would_term_return_404($category->term_id, 'category', $route)) {
1910 error_log("MetaSync OTTO: Skipping SEO processing for category that would return 404: {$route} (Category ID: {$category->term_id})");
1911 metasync_otto_auto_exclude_404_url($route);
1912 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1913 }
1914
1915 # Update comprehensive category SEO meta fields
1916 $update_result = metasync_update_comprehensive_category_seo_fields($category->term_id, $seo_data);
1917
1918 if ($update_result['updated']) {
1919 # Prepare trimmed values to 30 characters
1920 $trim = function($value) {
1921 if ($value === null) { return ''; }
1922 $value = (string) $value;
1923 $value = trim($value);
1924 if (mb_strlen($value) > 30) {
1925 return mb_substr($value, 0, 30);
1926 }
1927 return $value;
1928 };
1929
1930 # Log individual field updates for category
1931 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1932 $short = '';
1933 $title = '';
1934
1935 switch ($field_type) {
1936 case 'meta_title':
1937 $short = $trim($field_value);
1938 $title = "Category Meta Title Update ({$short}...)";
1939 break;
1940 case 'meta_description':
1941 $short = $trim($field_value);
1942 $title = "Category Meta Description Update ({$short}...)";
1943 break;
1944 case 'meta_keywords':
1945 $short = $trim($field_value);
1946 $title = "Category Meta Keywords Update ({$short}...)";
1947 break;
1948 case 'og_title':
1949 $short = $trim($field_value);
1950 $title = "Category Open Graph Title Update ({$short}...)";
1951 break;
1952 case 'og_description':
1953 $short = $trim($field_value);
1954 $title = "Category Open Graph Description Update ({$short}...)";
1955 break;
1956 case 'twitter_title':
1957 $short = $trim($field_value);
1958 $title = "Category Twitter Title Update ({$short}...)";
1959 break;
1960 case 'twitter_description':
1961 $short = $trim($field_value);
1962 $title = "Category Twitter Description Update ({$short}...)";
1963 break;
1964 case 'image_alt_data':
1965 $image_count = count($field_value);
1966 $title = "Category Image Alt Text Update ({$image_count} images)";
1967 break;
1968 case 'headings_data':
1969 $heading_count = count($field_value);
1970 $title = "Category Headings Update ({$heading_count} headings)";
1971 break;
1972 case 'structured_data':
1973 $title = 'Category Structured Data Update';
1974 break;
1975 }
1976
1977 if (is_scalar($field_value) && trim((string) $field_value) === '') {
1978 $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
1979 }
1980
1981 if (!empty($title)) {
1982 metasync_log_sync_history([
1983 'title' => $title,
1984 'source' => 'OTTO SEO',
1985 'status' => 'published',
1986 'content_type' => 'Category SEO',
1987 'url' => $route,
1988 'meta_data' => json_encode([
1989 'field' => $field_type,
1990 'field_value' => $field_value,
1991 'category_id' => $category->term_id,
1992 'category_name' => $category->name
1993 ])
1994 ]);
1995 }
1996 }
1997
1998 return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
1999 }
2000
2001 return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
2002 }
2003 }
2004
2005 # Check if this is a WooCommerce product category
2006 if (strpos($route, '/product-category/') !== false) {
2007 # Extract product category slug from URL
2008 $category_slug = basename(parse_url($route, PHP_URL_PATH));
2009 $term = get_term_by('slug', $category_slug, 'product_cat');
2010
2011 if ($term && !is_wp_error($term)) {
2012 # Check if product category would return 404 before applying OTTO changes
2013 if (metasync_would_term_return_404($term->term_id, 'product_cat', $route)) {
2014 error_log("MetaSync OTTO: Skipping SEO processing for product category that would return 404: {$route} (Term ID: {$term->term_id})");
2015 metasync_otto_auto_exclude_404_url($route);
2016 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2017 }
2018
2019 # Update comprehensive taxonomy SEO meta fields
2020 $update_result = metasync_update_comprehensive_taxonomy_seo_fields($term->term_id, 'product_cat', $seo_data);
2021
2022 if ($update_result['updated']) {
2023 # Prepare trimmed values to 30 characters
2024 $trim = function($value) {
2025 if ($value === null) { return ''; }
2026 $value = (string) $value;
2027 $value = trim($value);
2028 if (mb_strlen($value) > 30) {
2029 return mb_substr($value, 0, 30);
2030 }
2031 return $value;
2032 };
2033
2034 # Log individual field updates for product category
2035 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
2036 $short = '';
2037 $title = '';
2038
2039 switch ($field_type) {
2040 case 'meta_title':
2041 $short = $trim($field_value);
2042 $title = "Product Category Meta Title Update ({$short}...)";
2043 break;
2044 case 'meta_description':
2045 $short = $trim($field_value);
2046 $title = "Product Category Meta Description Update ({$short}...)";
2047 break;
2048 case 'meta_keywords':
2049 $short = $trim($field_value);
2050 $title = "Product Category Meta Keywords Update ({$short}...)";
2051 break;
2052 case 'og_title':
2053 $short = $trim($field_value);
2054 $title = "Product Category Open Graph Title Update ({$short}...)";
2055 break;
2056 case 'og_description':
2057 $short = $trim($field_value);
2058 $title = "Product Category Open Graph Description Update ({$short}...)";
2059 break;
2060 case 'twitter_title':
2061 $short = $trim($field_value);
2062 $title = "Product Category Twitter Title Update ({$short}...)";
2063 break;
2064 case 'twitter_description':
2065 $short = $trim($field_value);
2066 $title = "Product Category Twitter Description Update ({$short}...)";
2067 break;
2068 case 'image_alt_data':
2069 $image_count = count($field_value);
2070 $title = "Product Category Image Alt Text Update ({$image_count} images)";
2071 break;
2072 case 'headings_data':
2073 $heading_count = count($field_value);
2074 $title = "Product Category Headings Update ({$heading_count} headings)";
2075 break;
2076 case 'structured_data':
2077 $title = 'Product Category Structured Data Update';
2078 break;
2079 }
2080
2081 if (is_scalar($field_value) && trim((string) $field_value) === '') {
2082 $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2083 }
2084
2085 if (!empty($title)) {
2086 metasync_log_sync_history([
2087 'title' => $title,
2088 'source' => 'OTTO SEO',
2089 'status' => 'published',
2090 'content_type' => 'WooCommerce Product Category SEO',
2091 'url' => $route,
2092 'meta_data' => json_encode([
2093 'field' => $field_type,
2094 'field_value' => $field_value,
2095 'term_id' => $term->term_id,
2096 'term_name' => $term->name,
2097 'taxonomy' => 'product_cat'
2098 ])
2099 ]);
2100 }
2101 }
2102
2103 return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
2104 }
2105
2106 return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
2107 }
2108 }
2109
2110 # Check if this is the home page (landing page)
2111 $site_url = rtrim(site_url(), '/');
2112 $route_clean = rtrim($route, '/');
2113
2114 if ($route_clean === $site_url) {
2115 # Get the home page (front page)
2116 $front_page_id = get_option('page_on_front');
2117 $home_page = null;
2118
2119 if ($front_page_id && $front_page_id > 0) {
2120 $home_page = get_post($front_page_id);
2121 } else {
2122 # If no static front page is set, get the latest post
2123 $home_page = get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null;
2124 }
2125
2126 if ($home_page) {
2127 # Check if home page would return 404 before applying OTTO changes
2128 if (metasync_would_page_return_404($home_page->ID, $route)) {
2129 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})");
2130 metasync_otto_auto_exclude_404_url($route);
2131 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2132 }
2133
2134 # Update comprehensive home page SEO meta fields
2135 $update_result = metasync_update_comprehensive_seo_fields($home_page->ID, $seo_data);
2136
2137 if ($update_result['updated']) {
2138 # Clear relevant caches
2139 metasync_clear_post_seo_caches($home_page->ID);
2140
2141 # Prepare trimmed values to 30 characters
2142 $trim = function($value) {
2143 if ($value === null) { return ''; }
2144 $value = (string) $value;
2145 $value = trim($value);
2146 if (mb_strlen($value) > 30) {
2147 return mb_substr($value, 0, 30);
2148 }
2149 return $value;
2150 };
2151
2152 # Log individual field updates for home page
2153 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
2154 $short = '';
2155 $title = '';
2156
2157 switch ($field_type) {
2158 case 'meta_title':
2159 $short = $trim($field_value);
2160 $title = "Home Page Meta Title Update ({$short}...)";
2161 break;
2162 case 'meta_description':
2163 $short = $trim($field_value);
2164 $title = "Home Page Meta Description Update ({$short}...)";
2165 break;
2166 case 'meta_keywords':
2167 $short = $trim($field_value);
2168 $title = "Home Page Meta Keywords Update ({$short}...)";
2169 break;
2170 case 'og_title':
2171 $short = $trim($field_value);
2172 $title = "Home Page Open Graph Title Update ({$short}...)";
2173 break;
2174 case 'og_description':
2175 $short = $trim($field_value);
2176 $title = "Home Page Open Graph Description Update ({$short}...)";
2177 break;
2178 case 'twitter_title':
2179 $short = $trim($field_value);
2180 $title = "Home Page Twitter Title Update ({$short}...)";
2181 break;
2182 case 'twitter_description':
2183 $short = $trim($field_value);
2184 $title = "Home Page Twitter Description Update ({$short}...)";
2185 break;
2186 case 'image_alt_data':
2187 $image_count = count($field_value);
2188 $title = "Home Page Image Alt Text Update ({$image_count} images)";
2189 break;
2190 case 'headings_data':
2191 $heading_count = count($field_value);
2192 $title = "Home Page Headings Update ({$heading_count} headings)";
2193 break;
2194 case 'structured_data':
2195 $title = 'Home Page Structured Data Update';
2196 break;
2197 }
2198
2199 if (is_scalar($field_value) && trim((string) $field_value) === '') {
2200 $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2201 }
2202
2203 if (!empty($title)) {
2204 metasync_log_sync_history([
2205 'title' => $title,
2206 'source' => 'OTTO SEO',
2207 'status' => 'published',
2208 'content_type' => 'Home Page SEO',
2209 'url' => $route,
2210 'meta_data' => json_encode([
2211 'field' => $field_type,
2212 'field_value' => $field_value,
2213 'post_id' => $home_page->ID
2214 ])
2215 ]);
2216 }
2217 }
2218
2219 return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
2220 }
2221
2222 return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
2223 }
2224 }
2225
2226 # Check if this is the blog/posts page (page_for_posts)
2227 $posts_page_id = intval(get_option('page_for_posts'));
2228 if ($posts_page_id > 0) {
2229 $posts_page = get_post($posts_page_id);
2230 $posts_page_url = rtrim(get_permalink($posts_page_id), '/');
2231
2232 if ($posts_page && $route_clean === $posts_page_url) {
2233 # Check if blog page would return 404
2234 if (metasync_would_page_return_404($posts_page->ID, $route)) {
2235 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})");
2236 metasync_otto_auto_exclude_404_url($route);
2237 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2238 }
2239
2240 # Update comprehensive blog page SEO meta fields
2241 $update_result = metasync_update_comprehensive_seo_fields($posts_page->ID, $seo_data);
2242
2243 if ($update_result['updated']) {
2244 # Clear relevant caches
2245 metasync_clear_post_seo_caches($posts_page->ID);
2246
2247 # Prepare trimmed values to 30 characters
2248 $trim = function($value) {
2249 if ($value === null) { return ''; }
2250 $value = (string) $value;
2251 $value = trim($value);
2252 if (mb_strlen($value) > 30) {
2253 return mb_substr($value, 0, 30);
2254 }
2255 return $value;
2256 };
2257
2258 # Log individual field updates for blog page
2259 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
2260 $short = '';
2261 $title = '';
2262
2263 switch ($field_type) {
2264 case 'meta_title':
2265 $short = $trim($field_value);
2266 $title = "Blog Page Meta Title Update ({$short}...)";
2267 break;
2268 case 'meta_description':
2269 $short = $trim($field_value);
2270 $title = "Blog Page Meta Description Update ({$short}...)";
2271 break;
2272 case 'meta_keywords':
2273 $short = $trim($field_value);
2274 $title = "Blog Page Meta Keywords Update ({$short}...)";
2275 break;
2276 case 'og_title':
2277 $short = $trim($field_value);
2278 $title = "Blog Page Open Graph Title Update ({$short}...)";
2279 break;
2280 case 'og_description':
2281 $short = $trim($field_value);
2282 $title = "Blog Page Open Graph Description Update ({$short}...)";
2283 break;
2284 case 'twitter_title':
2285 $short = $trim($field_value);
2286 $title = "Blog Page Twitter Title Update ({$short}...)";
2287 break;
2288 case 'twitter_description':
2289 $short = $trim($field_value);
2290 $title = "Blog Page Twitter Description Update ({$short}...)";
2291 break;
2292 case 'image_alt_data':
2293 $image_count = count($field_value);
2294 $title = "Blog Page Image Alt Text Update ({$image_count} images)";
2295 break;
2296 case 'headings_data':
2297 $heading_count = count($field_value);
2298 $title = "Blog Page Headings Update ({$heading_count} headings)";
2299 break;
2300 case 'structured_data':
2301 $title = 'Blog Page Structured Data Update';
2302 break;
2303 }
2304
2305 if (is_scalar($field_value) && trim((string) $field_value) === '') {
2306 $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2307 }
2308
2309 if (!empty($title)) {
2310 metasync_log_sync_history([
2311 'title' => $title,
2312 'source' => 'OTTO SEO',
2313 'status' => 'published',
2314 'content_type' => 'Blog Page SEO',
2315 'url' => $route,
2316 'meta_data' => json_encode([
2317 'field' => $field_type,
2318 'field_value' => $field_value,
2319 'post_id' => $posts_page->ID
2320 ])
2321 ]);
2322 }
2323 }
2324
2325 return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
2326 }
2327
2328 return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
2329 }
2330 }
2331
2332 # URL didn't resolve to any supported entity (post, category, home page, blog page)
2333 # Treat as 404 and auto-exclude (e.g. deleted post, non-existent page)
2334 if (!metasync_otto_is_url_available($route)) {
2335 error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404 (no matching entity): {$route}");
2336 metasync_otto_auto_exclude_404_url($route);
2337 }
2338 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2339 }
2340
2341 # Verify this is actually a post, page, or WooCommerce product
2342 $post = get_post($post_id);
2343
2344 # Get supported post types dynamically
2345 $supported_post_types = metasync_get_supported_post_types();
2346
2347 if (!$post || !in_array($post->post_type, $supported_post_types)) {
2348 # Skip unsupported post types
2349 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2350 }
2351
2352 # Check if page would return 404 before applying OTTO changes
2353 if (metasync_would_page_return_404($post_id, $route)) {
2354 error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route} (Post ID: {$post_id}, Status: {$post->post_status})");
2355 metasync_otto_auto_exclude_404_url($route);
2356 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2357 }
2358
2359 # Update comprehensive SEO meta fields
2360 $update_result = metasync_update_comprehensive_seo_fields($post_id, $seo_data);
2361
2362 if ($update_result['updated']) {
2363 # Clear relevant caches
2364 metasync_clear_post_seo_caches($post_id);
2365
2366 # Prepare trimmed values to 30 characters
2367 $trim = function($value) {
2368 if ($value === null) { return ''; }
2369 $value = (string) $value;
2370 $value = trim($value);
2371 if (mb_strlen($value) > 30) {
2372 return mb_substr($value, 0, 30);
2373 }
2374 return $value;
2375 };
2376
2377 # Log individual field updates
2378 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
2379 $short = '';
2380 $title = '';
2381
2382 switch ($field_type) {
2383 case 'meta_title':
2384 $short = $trim($field_value);
2385 $title = 'Meta Title Update (' . $short . '...)';
2386 break;
2387 case 'meta_description':
2388 $short = $trim($field_value);
2389 $title = 'Meta Description Update (' . $short . '...)';
2390 break;
2391 case 'meta_keywords':
2392 $short = $trim($field_value);
2393 $title = 'Meta Keywords Update (' . $short . '...)';
2394 break;
2395 case 'og_title':
2396 $short = $trim($field_value);
2397 $title = 'Open Graph Title Update (' . $short . '...)';
2398 break;
2399 case 'og_description':
2400 $short = $trim($field_value);
2401 $title = 'Open Graph Description Update (' . $short . '...)';
2402 break;
2403 case 'twitter_title':
2404 $short = $trim($field_value);
2405 $title = 'Twitter Title Update (' . $short . '...)';
2406 break;
2407 case 'twitter_description':
2408 $short = $trim($field_value);
2409 $title = 'Twitter Description Update (' . $short . '...)';
2410 break;
2411 case 'image_alt_data':
2412 $image_count = count($field_value);
2413 $title = "Image Alt Text Update ({$image_count} images)";
2414 break;
2415 case 'headings_data':
2416 $heading_count = count($field_value);
2417 $title = "Headings Update ({$heading_count} headings)";
2418 break;
2419 case 'structured_data':
2420 $title = 'Structured Data Update';
2421 break;
2422 }
2423
2424 if (is_scalar($field_value) && trim((string) $field_value) === '') {
2425 $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2426 }
2427
2428 if (!empty($title)) {
2429 metasync_log_sync_history([
2430 'title' => $title,
2431 'source' => 'OTTO SEO',
2432 'status' => 'published',
2433 'content_type' => 'SEO Meta',
2434 'url' => $route,
2435 'meta_data' => json_encode([
2436 'field' => $field_type,
2437 'field_value' => $field_value,
2438 'post_id' => $post_id
2439 ])
2440 ]);
2441 }
2442 }
2443
2444 return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
2445 }
2446
2447 return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
2448
2449 } catch (Exception $e) {
2450 metasync_record_failed_action( 'metasync_process_seo_job' );
2451 return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
2452 } finally {
2453 # Release the concurrency lock only if we actually acquired it.
2454 # Early returns (CPU deferral, lock contention) must NOT delete a lock
2455 # that another process may be holding.
2456 if ($lock_acquired && $lock_key) {
2457 delete_transient($lock_key);
2458 }
2459 }
2460 }
2461
2462 /**
2463 * Log sync history entry
2464 * @param array $data Sync data to log
2465 */
2466 function metasync_log_sync_history($data) {
2467 try {
2468 // Classes are now autoloaded, no need for manual require
2469 $sync_db = new Metasync_Sync_History_Database();
2470
2471 // Minimal duplicate prevention within short time window
2472 if (!empty($data['title']) && !empty($data['source'])) {
2473 global $wpdb;
2474 $table = $wpdb->prefix . Metasync_Sync_History_Database::$table_name;
2475 $recent = $wpdb->get_var($wpdb->prepare(
2476 "SELECT COUNT(*) FROM `$table` WHERE title = %s AND source = %s AND created_at >= %s",
2477 $data['title'],
2478 $data['source'],
2479 gmdate('Y-m-d H:i:s', time() - 60)
2480 ));
2481 if ((int)$recent > 0) {
2482 return; // skip duplicate log within 60 seconds
2483 }
2484 }
2485
2486 $sync_db->add($data);
2487
2488 } catch (Exception $e) {
2489 error_log("MetaSync: Failed to log sync history: " . $e->getMessage());
2490 }
2491 }
2492
2493 /**
2494 * Get supported post types for OTTO SEO optimization
2495 * Includes WooCommerce products if WooCommerce is active
2496 *
2497 * @return array List of supported post types
2498 */
2499 function metasync_get_supported_post_types() {
2500 # Start with default post types
2501 $post_types = ['post', 'page'];
2502
2503 # Add WooCommerce product post type if WooCommerce is active
2504 if (class_exists('WooCommerce') || function_exists('is_woocommerce')) {
2505 $post_types[] = 'product';
2506 }
2507
2508 # Include all public custom post types (e.g. 'location', 'service', 'team', etc.)
2509 # so OTTO can write post meta for them during metasync_process_otto_seo_data().
2510 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
2511 if (!empty($custom_post_types)) {
2512 $post_types = array_merge($post_types, array_values($custom_post_types));
2513 }
2514
2515 # Allow developers to filter supported post types
2516 $post_types = apply_filters('metasync_otto_supported_post_types', $post_types);
2517
2518 return $post_types;
2519 }
2520
2521 /**
2522 * Get supported taxonomies for OTTO SEO optimization
2523 * Includes WooCommerce product categories and tags if WooCommerce is active
2524 *
2525 * @return array List of supported taxonomies
2526 */
2527 function metasync_get_supported_taxonomies() {
2528 # Start with default taxonomies
2529 $taxonomies = ['category'];
2530
2531 # Add WooCommerce taxonomies if WooCommerce is active
2532 if (class_exists('WooCommerce') || function_exists('is_woocommerce')) {
2533 $taxonomies[] = 'product_cat'; # WooCommerce product categories
2534 $taxonomies[] = 'product_tag'; # WooCommerce product tags
2535 }
2536
2537 # Allow developers to filter supported taxonomies
2538 $taxonomies = apply_filters('metasync_otto_supported_taxonomies', $taxonomies);
2539
2540 return $taxonomies;
2541 }
2542
2543 /**
2544 * Resolve a URL through the Redirect Manager table to its final destination (follows redirect chains).
2545 * Used before 404 checks and OTTO processing so the final canonical URL is used, not intermediate redirects.
2546 *
2547 * @param string $url Full URL (e.g. https://example.com/old-page)
2548 * @return string Final destination URL, or original $url if no redirect matches
2549 */
2550 function metasync_otto_resolve_redirect_to_final_url($url)
2551 {
2552 if (empty($url) || !is_string($url)) {
2553 return $url;
2554 }
2555 try {
2556 $db_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection-database.php';
2557 $class_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection.php';
2558 if (!file_exists($db_path) || !file_exists($class_path)) {
2559 return $url;
2560 }
2561 require_once $db_path;
2562 require_once $class_path;
2563 $db = new Metasync_Redirection_Database();
2564 $redirect = new Metasync_Redirection($db);
2565 return $redirect->resolve_url_to_final_destination($url, 10);
2566 } catch (Exception $e) {
2567 error_log('MetaSync OTTO: Redirect resolution failed for ' . $url . ' - ' . $e->getMessage());
2568 return $url;
2569 }
2570 }
2571
2572 /**
2573 * Auto-exclude a URL from OTTO with description "Auto-excluded: 404"
2574 * Called when a URL is detected as returning 404 so it won't be sent to OTTO again
2575 *
2576 * @param string $url Full URL to exclude (e.g. https://example.com/404-page)
2577 * @return bool|string True on success, false on failure, 'duplicate'/'reactivated' if already exists
2578 */
2579 function metasync_otto_auto_exclude_404_url($url)
2580 {
2581 if (empty($url) || !is_string($url)) {
2582 return false;
2583 }
2584 $url = filter_var($url, FILTER_SANITIZE_URL);
2585 $url = esc_url_raw($url);
2586 if (empty($url) || mb_strlen($url) > 2048) {
2587 return false;
2588 }
2589 try {
2590 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2591 $db = new Metasync_Otto_Excluded_URLs_Database();
2592 return $db->add([
2593 'url_pattern' => $url,
2594 'pattern_type' => 'exact',
2595 'description' => 'Auto-excluded: 404',
2596 'status' => 'active',
2597 'auto_excluded' => 1,
2598 ]);
2599 } catch (Exception $e) {
2600 error_log('MetaSync OTTO: Failed to auto-exclude 404 URL: ' . $url . ' - ' . $e->getMessage());
2601 return false;
2602 }
2603 }
2604
2605 /**
2606 * Remove a URL from the OTTO auto-exclusion list.
2607 * Called when OTTO sends a webhook for a URL, confirming it is valid and crawlable.
2608 * Only removes records where auto_excluded = 1 (never removes manual exclusions).
2609 *
2610 * @param string $url Full URL to un-exclude (e.g. https://example.com/location/page)
2611 * @return bool True on success
2612 */
2613 function metasync_otto_remove_auto_exclusion($url)
2614 {
2615 if (empty($url) || !is_string($url)) {
2616 return false;
2617 }
2618 try {
2619 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2620 $db = new Metasync_Otto_Excluded_URLs_Database();
2621 global $wpdb;
2622 $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
2623 # Normalize the same way is_url_excluded() does
2624 $url_normalized = rtrim(trim($url), '/');
2625 $records = $wpdb->get_results(
2626 $wpdb->prepare(
2627 "SELECT id FROM `{$table}` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'",
2628 $url_normalized
2629 )
2630 );
2631 if (!empty($records)) {
2632 $ids = array_map(function ($r) { return (int) $r->id; }, $records);
2633 $db->delete($ids);
2634 }
2635 return true;
2636 } catch (Exception $e) {
2637 return false;
2638 }
2639 }
2640
2641 /**
2642 * Check if a URL is MANUALLY excluded from OTTO (auto_excluded = 0).
2643 * Used at render time (metasync_start_otto) — auto-exclusions must NOT block
2644 * rendering because they are often false positives (e.g. custom post types that
2645 * url_to_postid() can't resolve). Auto-exclusions are only used to gate the
2646 * SEO meta-writing webhook path.
2647 *
2648 * @param string $url URL to check
2649 * @return bool True if URL has a manual exclusion
2650 */
2651 function metasync_is_otto_url_manually_excluded($url)
2652 {
2653 if (empty($url) || !is_string($url)) {
2654 return false;
2655 }
2656 try {
2657 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2658 global $wpdb;
2659 $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
2660 $url_normalized = rtrim(trim($url), '/');
2661
2662 $records = get_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY);
2663
2664 if ($records === false) {
2665 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- no user input, table name from $wpdb->prefix
2666 $records = $wpdb->get_results(
2667 "SELECT url_pattern, pattern_type FROM `{$table}` WHERE status = 'active' AND (auto_excluded = 0 OR auto_excluded IS NULL) ORDER BY created_at DESC"
2668 );
2669
2670 // Graceful recovery: auto_excluded column missing on pre-v2.7.4 installs.
2671 // Run ALTER TABLE to add it and treat URL as not excluded so OTTO continues rendering.
2672 if ($wpdb->last_error && strpos($wpdb->last_error, 'auto_excluded') !== false) {
2673 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
2674 $wpdb->query("ALTER TABLE `{$table}` ADD COLUMN `auto_excluded` TINYINT(1) NOT NULL DEFAULT 0");
2675 return false;
2676 }
2677
2678 set_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY, $records ?: [], METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL);
2679 }
2680
2681 if (empty($records)) {
2682 return false;
2683 }
2684
2685 foreach ($records as $excluded) {
2686 # Delegate to the shared matcher so this gate supports every pattern type the
2687 # add handler accepts (exact|contain|start|end|regex). Keeping a second switch
2688 # here is what let `end` and `regex` exclusions be silently ignored on the
2689 # visitor render path while still applying on the queue/SEO-write path.
2690 # The raw pattern is passed through - the matcher normalizes it per type,
2691 # because a regex must keep the trailing slash that closes its delimiters.
2692 if (Metasync_Otto_Excluded_URLs_Database::pattern_matches($url_normalized, $excluded->url_pattern, $excluded->pattern_type)) {
2693 return true;
2694 }
2695 }
2696 return false;
2697 } catch (Exception $e) {
2698 return false;
2699 }
2700 }
2701
2702 /**
2703 * Check if a URL is excluded from OTTO
2704 * @param string $url URL to check
2705 * @return bool True if URL is excluded, false otherwise
2706 */
2707 function metasync_is_otto_url_excluded($url)
2708 {
2709 try {
2710 // Load database class
2711 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2712 $db = new Metasync_Otto_Excluded_URLs_Database();
2713
2714 // Check if URL is excluded
2715 return $db->is_url_excluded($url);
2716
2717 } catch (Exception $e) {
2718 return false;
2719 }
2720 }
2721
2722 /**
2723 * Check if a URL is now available (would NOT return 404)
2724 * Uses same resolution logic as metasync_process_otto_seo_data
2725 * Used when rechecking auto-excluded 404 URLs after 7 days
2726 *
2727 * @param string $url Full URL to check (e.g. https://example.com/page)
2728 * @return bool True if URL is accessible, false if it would return 404
2729 */
2730 function metasync_otto_is_url_available($url)
2731 {
2732 if (empty($url) || !is_string($url)) {
2733 return false;
2734 }
2735
2736 $route = metasync_otto_resolve_redirect_to_final_url($url);
2737 $post_id = url_to_postid($route);
2738
2739 # WooCommerce shop page
2740 if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) {
2741 $shop_page_id = wc_get_page_id('shop');
2742 if ($shop_page_id > 0) {
2743 $shop_url = get_permalink($shop_page_id);
2744 if (rtrim($route, '/') === rtrim($shop_url, '/')) {
2745 $post_id = $shop_page_id;
2746 }
2747 }
2748 }
2749
2750 # WooCommerce product by slug
2751 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
2752 $product_slug = basename(parse_url($route, PHP_URL_PATH));
2753 $products = wc_get_products(array('name' => $product_slug, 'limit' => 1, 'status' => 'publish'));
2754 if (!empty($products)) {
2755 $post_id = $products[0]->get_id();
2756 } else {
2757 $query = new WP_Query(array(
2758 'post_type' => 'product',
2759 'name' => $product_slug,
2760 'posts_per_page' => 1,
2761 'post_status' => 'publish',
2762 ));
2763 if ($query->have_posts()) {
2764 $post_id = $query->posts[0]->ID;
2765 }
2766 }
2767 }
2768
2769 if ($post_id && $post_id > 0) {
2770 $post = get_post($post_id);
2771 # Accept any post type — custom post types (e.g. 'location', 'service') are valid URLs.
2772 # The old in_array check against metasync_get_supported_post_types() caused CPT URLs
2773 # to be wrongly auto-excluded as "404" pages.
2774 if ($post) {
2775 return !metasync_would_page_return_404($post_id, $route);
2776 }
2777 }
2778
2779 # Category
2780 if (strpos($route, '/category/') !== false) {
2781 $category_slug = basename(parse_url($route, PHP_URL_PATH));
2782 $category = get_category_by_slug($category_slug);
2783 if ($category) {
2784 return !metasync_would_term_return_404($category->term_id, 'category', $route);
2785 }
2786 }
2787
2788 # WooCommerce product category
2789 if (strpos($route, '/product-category/') !== false) {
2790 $category_slug = basename(parse_url($route, PHP_URL_PATH));
2791 $term = get_term_by('slug', $category_slug, 'product_cat');
2792 if ($term && !is_wp_error($term)) {
2793 return !metasync_would_term_return_404($term->term_id, 'product_cat', $route);
2794 }
2795 }
2796
2797 # Home page
2798 if (rtrim($route, '/') === rtrim(site_url(), '/')) {
2799 $front_page_id = get_option('page_on_front');
2800 $home_page = ($front_page_id && $front_page_id > 0)
2801 ? get_post($front_page_id)
2802 : (get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null);
2803 if ($home_page) {
2804 return !metasync_would_page_return_404($home_page->ID, $route);
2805 }
2806 }
2807
2808 # Could not verify availability from local data (custom archive, paginated page, etc.).
2809 # Assume the URL IS available — OTTO only crawls reachable URLs, so if we can't
2810 # prove it's a 404, we should not auto-exclude it.
2811 return true;
2812 }
2813
2814 /**
2815 * Recheck auto-excluded 404 URLs when recheck_after has passed; remove from exclusion if now available
2816 * Uses recheck_after timestamp (default 7 days from exclusion) to decide when to recheck
2817 * Mark as permanent after 30 days if still 404 (no further rechecks)
2818 * Called by daily cron job
2819 */
2820 function metasync_otto_recheck_404_exclusions()
2821 {
2822 try {
2823 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2824 $db = new Metasync_Otto_Excluded_URLs_Database();
2825 $records = $db->get_auto_excluded_404_urls_due_for_recheck();
2826
2827 if (empty($records)) {
2828 return;
2829 }
2830
2831 $removed = 0;
2832 $marked_permanent = 0;
2833 $thirty_days_ago = strtotime('-30 days');
2834 $next_recheck = date('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS);
2835
2836 foreach ($records as $record) {
2837 $url = trim($record->url_pattern);
2838 if (empty($url)) {
2839 continue;
2840 }
2841 if (metasync_otto_is_url_available($url)) {
2842 $db->delete([$record->id]);
2843 $removed++;
2844 } else {
2845 # Still 404: if excluded 30+ days ago, mark as permanent (no more rechecks)
2846 $created_ts = strtotime($record->created_at);
2847 if ($created_ts <= $thirty_days_ago) {
2848 $db->update(['is_permanent' => 1], $record->id);
2849 $marked_permanent++;
2850 } else {
2851 # Schedule next recheck in 7 days
2852 $db->update(['recheck_after' => $next_recheck], $record->id);
2853 }
2854 }
2855 }
2856
2857 if ($removed > 0) {
2858 error_log("MetaSync OTTO: Recheck 404 exclusions - removed {$removed} URL(s) that are now available");
2859 }
2860 if ($marked_permanent > 0) {
2861 error_log("MetaSync OTTO: Recheck 404 exclusions - marked {$marked_permanent} URL(s) as permanent (still 404 after 30 days)");
2862 }
2863 } catch (Exception $e) {
2864 error_log('MetaSync OTTO: Recheck 404 exclusions failed - ' . $e->getMessage());
2865 }
2866 }
2867
2868 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
2869
2870 /**
2871 * Check if a post/page would return 404 without making HTTP request
2872 * Uses WordPress database checks for fast validation
2873 *
2874 * @param int $post_id WordPress post ID
2875 * @param string $url The URL being checked (optional, for logging)
2876 * @return bool True if page would return 404, false if accessible
2877 */
2878 function metasync_would_page_return_404($post_id, $url = '') {
2879 if (!$post_id || $post_id <= 0) {
2880 return true; // No post ID = 404
2881 }
2882
2883 # Get the post object
2884 $post = get_post($post_id);
2885 if (!$post) {
2886 return true; // Post doesn't exist = 404
2887 }
2888
2889 # 1. Check post status - must be 'publish' to be publicly accessible
2890 if ($post->post_status !== 'publish') {
2891 return true; // Draft, pending, private, etc. = 404
2892 }
2893
2894 # 2. Check if post is password protected (requires password to view)
2895 if (!empty($post->post_password)) {
2896 # Password protected posts are not publicly accessible without password
2897 return true; // Password protected = effectively 404 for public
2898 }
2899
2900 # 3. Check if post is in trash
2901 if ($post->post_status === 'trash') {
2902 return true; // Trashed = 404
2903 }
2904
2905 # 4. Check if post type is publicly queryable
2906 $post_type_object = get_post_type_object($post->post_type);
2907 if ($post_type_object && !$post_type_object->publicly_queryable) {
2908 # Some post types might not be publicly accessible
2909 # But we allow if it's in our supported types
2910 $supported_post_types = metasync_get_supported_post_types();
2911 if (!in_array($post->post_type, $supported_post_types)) {
2912 return true; // Not publicly queryable = 404
2913 }
2914 }
2915
2916 # 5. WordPress 5.7+ has a built-in function for this
2917 if (function_exists('is_post_publicly_viewable')) {
2918 if (!is_post_publicly_viewable($post)) {
2919 return true; // Not publicly viewable = 404
2920 }
2921 }
2922
2923 # 6. Check if post is scheduled for future (not yet published)
2924 if ($post->post_date > current_time('mysql')) {
2925 return true; // Future post = 404 until publish date
2926 }
2927
2928 # All checks passed - page should be accessible
2929 return false;
2930 }
2931
2932 /**
2933 * Check if a taxonomy term (category, tag, etc.) would return 404
2934 * Uses WordPress database checks for fast validation
2935 *
2936 * @param int $term_id Term ID
2937 * @param string $taxonomy Taxonomy name (e.g., 'category', 'product_cat')
2938 * @param string $url The URL being checked (optional, for logging)
2939 * @return bool True if term would return 404, false if accessible
2940 */
2941 function metasync_would_term_return_404($term_id, $taxonomy, $url = '') {
2942 if (!$term_id || $term_id <= 0 || empty($taxonomy)) {
2943 return true; // Invalid term = 404
2944 }
2945
2946 # Get the term object
2947 $term = get_term($term_id, $taxonomy);
2948 if (is_wp_error($term) || !$term) {
2949 return true; // Term doesn't exist = 404
2950 }
2951
2952 # Check if taxonomy is publicly queryable
2953 $taxonomy_object = get_taxonomy($taxonomy);
2954 if (!$taxonomy_object || !$taxonomy_object->public) {
2955 # Check if it's in our supported taxonomies
2956 $supported_taxonomies = metasync_get_supported_taxonomies();
2957 if (!in_array($taxonomy, $supported_taxonomies)) {
2958 return true; // Not publicly queryable = 404
2959 }
2960 }
2961
2962 # Terms are generally always accessible if they exist and taxonomy is public
2963 # WordPress doesn't have a "draft" status for terms like posts do
2964 # But we can check if the term has a count (has posts assigned)
2965 # Empty terms might not be useful, but they're still accessible
2966
2967 # All checks passed - term should be accessible
2968 return false;
2969 }
2970
2971 /**
2972 * Invalidate Brizy posts cache when posts are saved
2973 * OPTIMIZATION: Clears transient cache to ensure accurate detection
2974 */
2975 add_action('save_post', function($post_id) {
2976 # Check if this post has Brizy metadata
2977 if (get_post_meta($post_id, 'brizy_post_uid', true)) {
2978 delete_transient('metasync_has_brizy_posts');
2979 }
2980 }, 10, 1);
2981
2982 /**
2983 * Identify infrastructure agents that must never hit the render throttle.
2984 *
2985 * Two classes of agent are covered:
2986 *
2987 * - Page-cache warmers. A host preloader (WP Cloud PageCacheBot, WP Rocket preload,
2988 * LiteSpeed, SG Optimizer, ...) exists purely to populate the page cache. Throttling
2989 * one returns un-OTTO'd HTML *and* defines DONOTCACHEPAGE, so the cache can never be
2990 * filled and every subsequent human visitor pays a full uncached render. Measured on
2991 * the reporting site: desktop cache MISS on 17/17 requests at ~2.8s TTFB, versus
2992 * ~0.56s with OTTO disabled.
2993 *
2994 * - Synthetic performance auditors (Lighthouse / PageSpeed Insights / GTmetrix).
2995 * These are what customers measure with, so they must receive the same fully
2996 * rendered OTTO output a real visitor gets. Currently `/lighthouse/i` and
2997 * `/pagespeed/i` are generic-bot patterns, so a second PSI run inside the 5-minute
2998 * window is served the throttled, un-OTTO'd, uncacheable page.
2999 *
3000 * Matching is done on the raw user-agent, deliberately independent of the bot-name
3001 * categorisation in Metasync_Otto_Bot_Detector.
3002 *
3003 * @param mixed $detection Result of Metasync_Otto_Bot_Detector::detect(), or malformed input.
3004 * @return bool True when this request must bypass the render throttle.
3005 */
3006 function metasync_otto_is_unthrottled_infrastructure_agent( $detection = array() ) {
3007
3008 $ua = '';
3009 if ( is_array( $detection ) && ! empty( $detection['user_agent'] ) ) {
3010 $ua = (string) $detection['user_agent'];
3011 } elseif ( isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
3012 $ua = (string) $_SERVER['HTTP_USER_AGENT'];
3013 }
3014
3015 $needles = array(
3016 // Page-cache preloaders.
3017 'pagecachebot', // WP Cloud / Automattic (PageCacheBotDesktop|Mobile)
3018 'wp rocket', // WP Rocket preload
3019 'wprocket',
3020 'lscache_runner', // LiteSpeed Cache crawler
3021 'sg-optimizer', // SiteGround
3022 'sgoptimizer',
3023 'nitropack',
3024 'cache-warmer',
3025 'cachewarmer',
3026 'cache warmer',
3027 // Synthetic performance auditors.
3028 'lighthouse',
3029 'pagespeed',
3030 'gtmetrix',
3031 'pingdom',
3032 'webpagetest',
3033 );
3034
3035 $is_infra = false;
3036
3037 if ( '' !== $ua ) {
3038 $ua_lower = strtolower( $ua );
3039 foreach ( $needles as $needle ) {
3040 if ( false !== strpos( $ua_lower, $needle ) ) {
3041 $is_infra = true;
3042 break;
3043 }
3044 }
3045 }
3046
3047 // WP Cloud's warmer also tags its requests with a query marker.
3048 if ( ! $is_infra && isset( $_GET['x-cache-engine'] ) ) {
3049 $is_infra = true;
3050 }
3051
3052 /**
3053 * Allow a site or host to declare additional preload agents.
3054 *
3055 * @param bool $is_infra Whether this request bypasses the OTTO render throttle.
3056 * @param string $ua The request user-agent.
3057 */
3058 if ( function_exists( 'apply_filters' ) ) {
3059 $is_infra = (bool) apply_filters( 'metasync_otto_unthrottled_agent', $is_infra, $ua );
3060 }
3061
3062 return $is_infra;
3063 }
3064