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

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

2,351 lines 101.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * This handles the OTTO SSR
4 */
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit;
7 }
8
9 if (!defined('METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY')) {
10 define('METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY', 'metasync_otto_excluded_urls_manual');
11 }
12 if (!defined('METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL')) {
13 define('METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL', 60);
14 }
15
16 # uses the simple html dom library
17 use simplehtmldom\HtmlDocument;
18
19 # include the otto class file
20 require_once plugin_dir_path( __FILE__ ) . '/vendor/autoload.php';
21 require_once plugin_dir_path( __FILE__ ) . '/Otto_html_class.php';
22 require_once plugin_dir_path( __FILE__ ) . '/Otto_pixel_class.php';
23 require_once plugin_dir_path( __FILE__ ) . '/metasync-otto-seo-functions.php';
24 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-transient-cache.php';
25 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-render-strategy.php';
26 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-config.php';
27 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-detector.php';
28 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-statistics-database.php';
29
30 # OPTIMIZED: get the metasync options (cached in static class)
31 $metasync_options = Metasync_Otto_Config::get_options();
32
33 # OTTO SSR is always enabled by default
34 $otto_enabled = true;
35
36 # add tag to wp head
37 add_action('wp_head', function(){
38 # load globals
39 global $metasync_options, $otto_enabled;
40
41 # OTTO SSR is always enabled
42 $string_enabled = 'true';
43
44 # OPTIMIZED: check uuid set using cached config
45 if(!Metasync_Otto_Config::is_otto_enabled()){
46 return;
47 }
48
49 # Performance optimization: Add DNS prefetch and preconnect for OTTO API
50 # This improves connection speed by resolving DNS and establishing connections early
51 # Use endpoint manager to get the correct domain
52 $otto_domain = 'sa.searchatlas.com'; # default
53 if (class_exists('Metasync_Endpoint_Manager')) {
54 $otto_api_domain = Metasync_Endpoint_Manager::get_endpoint('OTTO_API_DOMAIN');
55 $parsed = parse_url($otto_api_domain);
56 if (!empty($parsed['host'])) {
57 $otto_domain = $parsed['host'];
58 }
59 }
60 echo '<link rel="dns-prefetch" href="//' . esc_attr($otto_domain) . '">' . "\n";
61 echo '<link rel="preconnect" href="https://' . esc_attr($otto_domain) . '" crossorigin>' . "\n";
62
63 # adding the otto tag to pages
64 $plugin_version = defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown';
65 # OPTIMIZED: use cached uuid
66 $otto_tag = '<meta name="otto" content="uuid='.esc_attr(Metasync_Otto_Config::get_otto_uuid()).'; type=wordpress; enabled='.esc_attr($string_enabled).'; version='.esc_attr($plugin_version).'">';
67
68 # out the otto tag
69 echo $otto_tag;
70 }, 1); # Priority 1 to output early in head
71
72 /**
73 * Start end point to handle requests on page updates
74 * The Otto Crawler will call this end point once a page is updated
75 **/
76
77 # function to register the route
78 function metasync_otto_crawl_notify($request){
79
80 # get request data params
81 $data = $request->get_json_params();
82
83 # fields
84 $fields = ['domain', 'urls'];
85
86 # validate the json request
87 foreach ($fields as $key => $value) {
88
89 # if the value is empty stop tehre
90 if(empty($data[$value])){
91
92 # Handle the POST request
93 return new WP_REST_Response(array(
94 'success' => false,
95 'message' => 'Invalid Field : '. $value,
96 ), 400);
97 }
98
99 }
100
101 # load otto pixel
102 $otto_pixel = new Metasync_otto_pixel(false);
103
104 # save the otto data first (cheap local DB write — kept synchronous)
105 $otto_pixel->save_crawl_data($data);
106
107 # Defer the expensive per-URL work (OTTO API fetch, post meta sync,
108 # per-URL cache purge) to background jobs. The webhook caller enforces a
109 # strict response-time budget; doing this work inline previously blocked
110 # the response for up to 30s × N URLs and timed the caller out.
111 # Each URL gets its own scheduled event so failures are isolated.
112 #
113 # Cap the number of per-URL cron jobs scheduled per webhook batch.
114 # Large crawl batches (100+ URLs) previously created hundreds of cron events
115 # that overwhelmed WP-Cron on shared/managed hosts like WP Engine. Excess
116 # URLs beyond the cap are silently skipped — OTTO will re-crawl them on the
117 # next webhook cycle.
118 $max_jobs_per_batch = defined('METASYNC_MAX_JOBS_PER_BATCH') ? METASYNC_MAX_JOBS_PER_BATCH : 25;
119 $now = time();
120 $routes_to_process = array();
121 $scheduled_count = 0;
122
123 foreach($data['urls'] AS $key => $url){
124 # Enforce per-batch cap to prevent cron overload
125 if ($scheduled_count >= $max_jobs_per_batch) {
126 break;
127 }
128
129 # prepare the route
130 $route = $data['domain'] . $url;
131
132 # validate the route
133 $route = rtrim($route, '/');
134
135 # Resolve redirect table: use final destination URL before excluded/404 checks and OTTO processing
136 $route = metasync_otto_resolve_redirect_to_final_url($route);
137
138 # OTTO has confirmed this URL is crawlable. Remove any auto-exclusion that was
139 # previously set (e.g. because url_to_postid() returned 0 for a custom post type
140 # and metasync_otto_is_url_available() incorrectly treated it as a 404).
141 # Manual exclusions (auto_excluded = 0) are left untouched.
142 metasync_otto_remove_auto_exclusion($route);
143
144 # Skip manually excluded URLs - don't queue OTTO processing for them
145 if (metasync_is_otto_url_excluded($route)) {
146 continue;
147 }
148
149 # Queue the per-URL processing for background execution. The handler
150 # (metasync_handle_otto_crawl_url_job) performs transient warming,
151 # SEO meta sync, and per-URL host cache purge.
152 # Offset each event by $key seconds to avoid wp_schedule_single_event()
153 # silently dropping duplicates when timestamp + hook + args collide.
154 wp_schedule_single_event($now + $key, 'metasync_process_otto_crawl_url_job', array($route));
155
156 $routes_to_process[] = $route;
157 $scheduled_count++;
158 }
159
160 # Schedule a single batch job for cache warming and edge CDN purge.
161 # These operations benefit from batching: warm_urls() uses concurrent
162 # fire-and-forget requests, and edge providers (Cloudflare, Fastly)
163 # support multi-URL purge in a single API call.
164 if (!empty($routes_to_process)) {
165 $batch_time = $now + count($data['urls']) + 5; // run after all per-URL jobs
166 wp_schedule_single_event($batch_time, 'metasync_process_otto_batch_cache_job', array($routes_to_process));
167 }
168
169 # Track OTTO optimization event in GA4 (local, non-blocking)
170 try {
171 Metasync_GA4::get_instance()->track_otto_optimization($data);
172 } catch (Exception $e) {
173 // Analytics tracking failed, continue
174 }
175
176 # Return 200 immediately so the webhook caller does not time out.
177 return new WP_REST_Response(array(
178 'success' => true,
179 'message' => 'OTTO crawl notification received',
180 ), 200);
181 }
182
183 /**
184 * Background handler for a single OTTO crawl-notify URL.
185 *
186 * Runs the per-URL sequence that was previously inline in metasync_otto_crawl_notify():
187 * warm the OTTO transient cache, sync SEO post meta, and clear the per-URL host cache.
188 *
189 * Cache warming and edge CDN purge are handled separately by
190 * metasync_handle_otto_batch_cache_job() in a single batched event, because
191 * those operations benefit from multi-URL batching (fewer API calls).
192 *
193 * On failure, retries up to METASYNC_OTTO_JOB_MAX_RETRIES times with 60s backoff.
194 * After all retries are exhausted the failure is recorded via
195 * metasync_record_failed_action() for surfacing in Site Health.
196 *
197 * @param string $route Fully-qualified URL to process.
198 * @param int $retry_count Current retry attempt (0 = first run).
199 */
200 function metasync_handle_otto_crawl_url_job($route, $retry_count = 0) {
201 try {
202 # Step 1: Warm OTTO transient cache (fetch fresh suggestions from OTTO API into WP transient)
203 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
204 if (!empty($otto_uuid)) {
205 $transient_cache = new Metasync_Otto_Transient_Cache($otto_uuid);
206 $transient_cache->warm_cache($route);
207 }
208
209 # Step 2: Write SEO post meta synchronously BEFORE the cache purge.
210 # This ensures the DB is fully up-to-date when Kinsta (or any host) re-populates
211 # the cache on the very next request — eliminating the race condition where the
212 # 1-second scheduled job hadn't run yet and stale meta got cached.
213 # allow_defer=false: do NOT reschedule a new metasync_process_seo_job cron
214 # event from this synchronous path — the crawl_url_job retry mechanism already
215 # handles failures, and rescheduling here caused an unbounded cron pile-up.
216 metasync_process_otto_seo_data($route, false);
217
218 # Step 3: Clear the per-URL cache entry
219 $otto_pixel = new Metasync_otto_pixel(false);
220 $otto_pixel->refresh_cache($route);
221 Metasync_Cache_Purge::purge_single_url($route);
222
223 } catch (Exception $e) {
224 $max_retries = defined('METASYNC_OTTO_JOB_MAX_RETRIES') ? METASYNC_OTTO_JOB_MAX_RETRIES : 3;
225
226 if ($retry_count < $max_retries) {
227 # Schedule a retry with exponential backoff: 60s, 120s, 240s
228 $delay = 60 * pow(2, $retry_count);
229 wp_schedule_single_event(time() + $delay, 'metasync_process_otto_crawl_url_job', array($route, $retry_count + 1));
230 error_log('MetaSync OTTO: retrying crawl-url job for ' . $route . ' (attempt ' . ($retry_count + 1) . '/' . $max_retries . ') in ' . $delay . 's: ' . $e->getMessage());
231 } else {
232 metasync_record_failed_action('metasync_process_otto_crawl_url_job');
233 error_log('MetaSync OTTO: background crawl-url job permanently failed for ' . $route . ' after ' . $max_retries . ' retries: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
234 }
235 }
236 }
237
238 /**
239 * Batch handler for cache warming and edge CDN purge.
240 *
241 * Runs after all per-URL jobs have had time to complete. Batching these
242 * operations avoids N separate API calls to edge providers (Cloudflare
243 * supports up to 30 tags per request) and lets warm_urls() issue concurrent
244 * fire-and-forget requests.
245 *
246 * @param array $routes List of fully-qualified URLs to warm and purge.
247 */
248 function metasync_handle_otto_batch_cache_job($routes) {
249 try {
250 # Re-populate the cache so OTTO-modified output is what gets stored.
251 # Per-URL host cache was already cleared by individual jobs.
252 # warm_urls() hits each URL with a non-blocking request so our code —
253 # with fresh transients and post meta — is first to populate the host cache.
254 Metasync_Cache_Purge::warm_urls($routes);
255
256 # Purge edge CDN caches (Cloudflare, Fastly, Akamai, Sucuri, Sevalla, etc.)
257 # Tag-based providers purge only the affected posts; full-flush providers fire once per batch.
258 Metasync_Edge_Cache_Purge::purge($routes);
259 } catch (Exception $e) {
260 metasync_record_failed_action('metasync_process_otto_batch_cache_job');
261 error_log('MetaSync OTTO: batch cache job failed for ' . count($routes) . ' URLs: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
262 }
263 }
264
265 # NOTE: Cache system removed - these functions are no longer needed
266 # Kept for backward compatibility in case old cache directories need cleanup
267 function metasync_deleteDir($dir) {
268 if (!is_dir($dir)) {
269 return false;
270 }
271 $files = array_diff(scandir($dir), array('.', '..'));
272 foreach ($files as $file) {
273 $filePath = $dir . DIRECTORY_SEPARATOR . $file;
274 if (is_dir($filePath)) {
275 metasync_deleteDir($filePath);
276 } else {
277 unlink($filePath);
278 }
279 }
280 return rmdir($dir);
281 }
282
283 # Cleanup function for removing old cache directories (if they exist)
284 function metasync_invalidate_all_caches($folder = ''){
285 # Cache system removed - this function only exists to clean up old cache directories
286 if(!defined('WP_CONTENT_DIR')){
287 return false;
288 }
289 $wp_content_dir = WP_CONTENT_DIR;
290 $cache_dir = $wp_content_dir . '/metasync_caches';
291 if(in_array($folder, ['posts', 'pages'])){
292 $cache_dir = $cache_dir . '/' . $folder;
293 }
294 if(is_dir($cache_dir)){
295 metasync_deleteDir($cache_dir);
296 }
297 }
298
299 // metasync_is_custom_or_lps_page() now lives in includes/metasync-helpers.php
300 // (loaded unconditionally before this file) so all SEO surfaces share one rule.
301
302 /**
303 * Detect an Elementor editor / preview request that OTTO must never process.
304 *
305 * Elementor renders its editing canvas in a front-end <iframe> — a logged-in
306 * GET request that is NOT is_admin(), so the admin/ajax/REST guards in
307 * metasync_start_otto() do not catch it. If OTTO output-buffers and rewrites
308 * that response (SimpleHtmlDom in Otto_html_class), the markup the editor's
309 * JavaScript expects is altered and the canvas stays stuck on "loading" —
310 * the editor never finishes initialising. The editor surface is for authoring
311 * only; it is never crawled, so there is nothing for OTTO to optimise there.
312 *
313 * Locally this is masked because the OTTO API is unreachable (API_ERROR ⇒ no
314 * suggestions ⇒ no rewrite); on a live site with real suggestions the rewrite
315 * happens and the editor breaks. See the "Elementor editor fails to load while
316 * the plugin is active" report.
317 *
318 * @return bool True when the current request is an Elementor editor/preview.
319 */
320 function metasync_is_elementor_editor_request() {
321 # The preview iframe loads the front end with ?elementor-preview=POST_ID.
322 # This is the request OTTO would otherwise buffer and corrupt.
323 if (isset($_GET['elementor-preview'])) {
324 return true;
325 }
326
327 # Elementor editor / app entry points carried as an action on the front end.
328 if (isset($_REQUEST['action'])
329 && in_array($_REQUEST['action'], array('elementor', 'elementor_ajax'), true)
330 ) {
331 return true;
332 }
333
334 # Authoritative check when Elementor is loaded: its own preview-mode flag.
335 if (class_exists('\Elementor\Plugin')
336 && isset(\Elementor\Plugin::$instance->preview)
337 && is_object(\Elementor\Plugin::$instance->preview)
338 && method_exists(\Elementor\Plugin::$instance->preview, 'is_preview_mode')
339 && \Elementor\Plugin::$instance->preview->is_preview_mode()
340 ) {
341 return true;
342 }
343
344 return false;
345 }
346
347 function metasync_start_otto(){
348
349 # PERFORMANCE FIX: Cache is now enabled for speed
350 # Skip initial cache cleanup to preserve existing cache
351 # Cache files are valuable for performance - only clear on OTTO updates
352 # Periodic cache clearing can be configured in plugin settings if needed
353
354 # exclude AJAX requests and WooCommerce transactional pages from OTTO SSR
355 # SSR is now ENABLED for: single products, product categories, product tags
356 # SSR is SKIPPED for: cart, checkout
357 # Note: title/description filters (pre_get_document_title, wp_head meta desc)
358 # run on ALL pages regardless — they are hooked unconditionally in seo-functions.php
359
360 # ── WooCommerce-independent cart/checkout protection ──────────
361 # The is_cart()/is_checkout() guards below only recognize WooCommerce. Carts
362 # from other systems — e.g. the Point of Rental "Catalog" plugin on
363 # venturarental.com — are invisible to them, so OTTO would process those
364 # pages and let caching layers store them. A cached cart page corrupts the
365 # live cart (added items vanish because a stale page is served). The checks
366 # here do not depend on WooCommerce being active.
367
368 # Never run OTTO on non-GET requests. SSR exists for crawlers, which only
369 # issue GET; POST/PUT/etc. are form or cart submissions that must pass
370 # through untouched. (OTTO's own internal fetches use GET.)
371 if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
372 return;
373 }
374
375 # Never run OTTO on the Elementor editor/preview iframe. It is a logged-in
376 # front-end GET (not is_admin()), so it slips past the admin guard below;
377 # buffering and rewriting it breaks the editor canvas ("fails to load").
378 if (metasync_is_elementor_editor_request()) {
379 return;
380 }
381
382 # WP core file-editor self-check. When an admin saves a THEME file
383 # via the Plugin/Theme Editor, wp_edit_theme_plugin_file() fires an internal
384 # loopback GET to home_url('/') carrying wp_scrape_key/wp_scrape_nonce to
385 # detect a white-screen. That request is NOT is_admin(), so — exactly like
386 # the Elementor case above — it slips past the admin/AJAX/REST guards below.
387 # OTTO must not output-buffer or rewrite it: the SimpleHtmlDom pass (and any
388 # fatal thrown inside the ob_start() callback, e.g. under memory pressure)
389 # corrupts the scrape and surfaces as the misleading
390 # "preg_match(): Cannot use output buffering in output buffering display
391 # handlers" fatal. Skipping OTTO lets WP render the page normally so the
392 # scrape works. Returning here also avoids the HTTP-fallback path firing a
393 # second loopback for an already-internal self-request.
394 if (metasync_is_scrape_request()) {
395 return;
396 }
397
398 # Skip OTTO on cart/checkout-type paths regardless of the cart plugin, and
399 # signal caching plugins (WP Rocket/Kinsta/etc.) to never cache them — a
400 # cached cart/checkout page is precisely what corrupts cart state.
401 # Matched against the FIRST path segment (relative to the WP home path) so
402 # subdirectory installs (/shop/cart) still work, while unrelated content pages
403 # like /blog/cart or /features/checkout do NOT false-match.
404 # Drop the query string with explode() (no shared internal pointer like strtok),
405 # then lower-case and strip surrounding slashes.
406 $otto_req_path = explode('?', (string) wp_unslash($_SERVER['REQUEST_URI'] ?? ''), 2)[0];
407 $otto_req_path = strtolower(trim($otto_req_path, '/'));
408 # Strip the site's base path so first-segment matching works on subdir installs.
409 $otto_home_path = trim((string) parse_url(home_url(), PHP_URL_PATH), '/');
410 if ($otto_home_path !== '' && strpos($otto_req_path, $otto_home_path . '/') === 0) {
411 $otto_req_path = substr($otto_req_path, strlen($otto_home_path) + 1);
412 }
413 $otto_first_segment = explode('/', $otto_req_path, 2)[0];
414 # Filterable so sites can add/remove transactional slugs without code changes.
415 $otto_cart_segments = apply_filters('metasync_otto_cart_paths', array(
416 'cart', 'checkout', 'basket', 'request-a-quote', 'quote-request',
417 ));
418 $otto_cart_segments = array_map('strtolower', (array) $otto_cart_segments);
419 if ($otto_first_segment !== '' && in_array($otto_first_segment, $otto_cart_segments, true)) {
420 if (!defined('DONOTCACHEPAGE')) {
421 define('DONOTCACHEPAGE', true);
422 }
423 return;
424 }
425 # ──────────────────────────────────────────────────────────────────────
426
427 if (
428 # disable ajax calls
429 isset($_GET['ucfrontajaxaction']) ||
430 # OTTO Preview mode - skip OTTO when previewing original content
431 (isset($_GET['otto_preview']) && $_GET['otto_preview'] === '1') ||
432 # WooCommerce shop archive page only (products and categories now use SSR)
433 //(function_exists('is_shop') && is_shop()) ||
434 # Cart page
435 (function_exists('is_cart') && is_cart()) ||
436 # Checkout page
437 (function_exists('is_checkout') && is_checkout()) ||
438 # My Account page
439 //(function_exists('is_account_page') && is_account_page()) ||
440 # Standard WordPress AJAX
441 (function_exists('wp_doing_ajax') && wp_doing_ajax()) ||
442 # check by constant
443 (defined('DOING_AJAX') && DOING_AJAX) ||
444 # WooCommerce AJAX endpoint (e.g., ?wc-ajax=update_cart)
445 (isset($_REQUEST['wc-ajax']) && !empty($_REQUEST['wc-ajax'])) ||
446 # AJAX requests via X-Requested-With header
447 (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') ||
448 # Gravity Forms submission detection - skip OTTO to allow form processing
449 (isset($_POST['gform_submit']) && (
450 is_array($_POST['gform_submit']) ||
451 (is_string($_POST['gform_submit']) && isset($_POST['is_submit_' . $_POST['gform_submit']]) && !empty($_POST['gform_submit']))
452 )) ||
453 # Gravity Forms AJAX submission
454 (isset($_POST['gform_ajax']) && isset($_POST['gform_submit']) && (
455 is_array($_POST['gform_submit']) || !empty($_POST['gform_submit'])
456 )) ||
457 # Gravity Forms file upload
458 (isset($_POST['gform_uploaded_files'])) ||
459 # Any Gravity Forms POST parameter
460 (isset($_POST['gform_submit']) || isset($_POST['gform_unique_id']) || isset($_POST['gform_field_values'])) ||
461 # Formidable Forms AJAX submission detection - skip OTTO to allow form processing
462 (isset($_POST['action']) && $_POST['action'] === 'frm_entries_create') ||
463 # Formidable Forms POST parameters
464 (isset($_POST['form_id']) && !empty($_POST['form_id'])) ||
465 # Formidable Forms action parameter
466 (isset($_POST['frm_action']) && !empty($_POST['frm_action'])) ||
467 # Formidable Forms item_key (used in form submissions)
468 (isset($_POST['item_key']) && !empty($_POST['item_key']))
469 ) {
470 return;
471 }
472
473 # fetch globals
474 global $metasync_options, $otto_enabled;
475
476 # OPTIMIZED: check for the disable otto for logged in users option using cached config
477 if(Metasync_Otto_Config::is_disabled_for_loggedin()){
478
479 # get user
480 $current_user = wp_get_current_user();
481
482 # check if user is logged in
483 if( !empty($current_user->ID)){
484
485 return;
486 }
487 }
488
489 # check if current URL is manually excluded from OTTO
490 # NOTE: auto-exclusions (false-positive 404 detections) are intentionally NOT checked
491 # here — they must not block OTTO rendering. Use metasync_is_otto_url_excluded() only
492 # in the webhook handler where we gate SEO meta writes.
493 $request_uri = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
494 $current_url = home_url(strtok($request_uri, '?') ?: $request_uri);
495 if (metasync_is_otto_url_manually_excluded($current_url)) {
496 return;
497 }
498
499 # Skip OTTO for XML endpoints (sitemaps, RSS-as-xml, etc.). OTTO has no
500 # suggestions for machine-readable XML, and the upstream API returns
501 # API_ERROR for these URLs — which then stamps misleading
502 # X-MetaSync-OTTO-Cache: API_ERROR / X-MetaSync-OTTO-Method: NONE headers
503 # onto otherwise-healthy sitemap responses and causes false-alarm bug
504 # reports from customers.
505 $request_path = strtok($request_uri, '?');
506 if ($request_path && preg_match('#\.xml$#i', $request_path)) {
507 return;
508 }
509
510 # BOT DETECTION: Always detect bots so crawl data reaches the SA backend.
511 # Real bots don't execute JS, so otto-tracker.js never fires for them.
512 # push_crawl_log_to_sa() fires a non-blocking wp_remote_post here instead.
513 $bot_detector = Metasync_Otto_Bot_Detector::get_instance();
514 $detection = $bot_detector->detect();
515 if ( $detection['is_bot'] ) {
516 $bot_detector->push_crawl_log_to_sa( $detection, $current_url );
517 }
518
519 # Throttle OTTO rendering for non-search-engine bots: at most one render
520 # per URL+bot every 5 minutes. Humans and verified search engines
521 # (Googlebot, Bingbot, etc.) are never throttled — their hits always get
522 # full OTTO content so indexing remains current. SEO tools, AI scrapers,
523 # uptime monitors, and unverified crawlers are de-duplicated so repeat
524 # hits don't re-trigger the expensive DOM-rewriting path.
525 if ( $detection['is_bot']
526 && $detection['bot_type'] !== 'search_engine'
527 && ! metasync_otto_is_unthrottled_infrastructure_agent( $detection ) ) {
528 $normalized_url = strtok( $current_url, '?' );
529 if ( $normalized_url === false ) {
530 $normalized_url = $current_url;
531 }
532 // Lowercase + length cap so attacker-controlled bot_name variations
533 // (EvilBota, EvilBotB, EvilBotc, ...) collapse to a bounded key space
534 // and cannot flood wp_options with unique transient rows.
535 $bot_name = substr( strtolower( $detection['bot_name'] ?? 'unknown' ), 0, 32 );
536 $render_throttle_key = 'metasync_otto_rendered_' . md5( $normalized_url . '|' . $bot_name );
537 if ( get_transient( $render_throttle_key ) ) {
538 // Prevent page caches (WP Rocket, W3TC, etc.) from saving the
539 // un-OTTO'd response and serving it to subsequent human visitors.
540 if ( ! defined( 'DONOTCACHEPAGE' ) ) {
541 define( 'DONOTCACHEPAGE', true );
542 }
543 return;
544 }
545 set_transient( $render_throttle_key, 1, 5 * MINUTE_IN_SECONDS );
546 }
547
548 # Optionally skip OTTO processing for bot traffic (when the setting is enabled)
549 if ($bot_detector->should_skip_otto()) {
550 // Log the bot locally and count the saved API call
551 $bot_detector->log_detection($detection);
552 $bot_stats_db = Metasync_Otto_Bot_Statistics_Database::get_instance();
553 $bot_stats_db->increment_api_calls_saved();
554
555 // Skip OTTO processing for this bot
556 return;
557 }
558
559 # OPTIMIZED: Check if Otto should be disabled for WP Rocket compatibility
560 if (class_exists('WP_Rocket')) {
561 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
562
563 if ($wp_rocket_compat_mode === 'disable_otto') {
564 return; # Exit early, Otto is disabled when WP Rocket is active
565 }
566 }
567
568 # Skip OTTO for Divi AJAX pagination and paginated archive requests.
569 # ?et_blog = Divi AJAX pagination callback
570 # /page/N/ = paginated blog/archive pages — OTTO's buffer/HTTP render causes
571 # module numbering mismatch between page 1 (with TB template) and page N
572 # (without TB template), breaking Divi's JS pagination selector matching.
573 if (isset($_GET['et_blog']) || (is_paged() && !is_singular())) {
574 return;
575 }
576
577 # Skip OTTO on search-results pages. A search request carries its
578 # meaning entirely in the query string (e.g. /?s=term, or FiboSearch's
579 # /?s=term&post_type=product&dgwt_wcas=1). get_route() intentionally strips
580 # the query string to build a canonical, cache-key-stable route — which
581 # collapses a query-only search URL to the site root ('/'). OTTO then finds
582 # the HOME page's suggestions for that route and, on the HTTP render path,
583 # serves the home page in place of the search results (the address bar keeps
584 # the search URL — it is a silent content swap, not a redirect). This only
585 # surfaces for logged-in / uncached visitors, since page caches serve the
586 # correct pre-rendered search page to everyone else. Search-results pages
587 # have no per-canonical-URL OTTO suggestions of their own, so OTTO must never
588 # run here. The raw $_GET['s'] check is a defensive fallback for setups where
589 # the main query is altered before this point.
590 if (is_search() || !empty($_GET['s'])) {
591 return;
592 }
593
594 # Handle cache plugin compatibility early - before any caching happens
595 metasync_otto_handle_cache_compatibility();
596
597 # check if OTTO is disabled for this specific page/post
598 $post_id = get_the_ID();
599 if ($post_id && class_exists('Metasync_Otto_Frontend_Toolbar')) {
600 if (Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id)) {
601 return;
602 }
603 }
604
605 # Skip OTTO on MetaSync custom HTML / LPS-imported pages — they ship their
606 # own complete, self-contained SEO and OTTO must not inject or overwrite it
607 # with a different/older project's SEO. Resolve the queried object
608 # id (with get_the_ID() fallback) so the static-front-page case — an LPS home
609 # set as the WP front page, where is_page() is false — is still detected.
610 # Applying the skip here, upstream of the single render_route_html() entry,
611 # covers all three render paths (Rocket buffer, output buffer, HTTP fallback).
612 # Only singular views (posts/pages, incl. a static front page) can be a
613 # custom/LPS page; gate on is_singular() so an archive/search/term query
614 # can never have its object id mistaken for a custom page's post id.
615 $custom_page_id = is_singular() ? ( get_queried_object_id() ?: get_the_ID() ) : 0;
616 if (metasync_is_custom_or_lps_page($custom_page_id)) {
617 if (!headers_sent()) {
618 header('X-MetaSync-OTTO-Method: EXCLUDED');
619 }
620 return;
621 }
622
623 # check if we are having an otto request
624 if(!empty($_GET['is_otto_page_fetch'])){
625
626 # Block SEO plugins NOW for this internal fetch request
627 # metasync_otto_block_seo_plugins();
628 # $_SERVER['REQUEST_URI'] = remove_query_arg('is_otto_page_fetch', $_SERVER['REQUEST_URI']);
629 $block_title = !empty($_GET['otto_block_title']) && $_GET['otto_block_title'] === '1';
630 $block_description = !empty($_GET['otto_block_desc']) && $_GET['otto_block_desc'] === '1';
631
632 # Block SEO plugins conditionally based on what Otto has
633 if ($block_title || $block_description) {
634 metasync_otto_block_seo_plugins($block_title, $block_description);
635 }
636
637 # Remove ALL Otto parameters from REQUEST_URI to prevent them from appearing in pagination, etc.
638 $request_uri_raw = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
639 $_SERVER['REQUEST_URI'] = remove_query_arg(
640 ['is_otto_page_fetch', 'otto_block_title', 'otto_block_desc'],
641 $request_uri_raw
642 );
643
644 # Also remove from $_GET to prevent WordPress from using them
645 unset($_GET['is_otto_page_fetch']);
646 unset($_GET['otto_block_title']);
647 unset($_GET['otto_block_desc']);
648 return;
649 }
650
651 # to avoid unnecessary processese
652 # OPTIMIZED: check that otto is configured
653 # And the UUID is properly set before running OTT
654
655 # check that we have the option
656 if(!Metasync_Otto_Config::is_otto_enabled()){
657 return;
658 }
659
660 # check that otto is enabled
661 if(!$otto_enabled){
662 return;
663 }
664
665 # get the otto uuid
666 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
667
668 # start the class
669 $otto = new Metasync_otto_pixel($otto_uuid);
670
671 # call render
672 $otto->render_route_html();
673 }
674
675 /**
676 * Handle cache plugin compatibility with Otto
677 * Controls DONOTCACHEPAGE constant based on active plugins and configuration
678 * This function is called early in the WordPress lifecycle
679 */
680 function metasync_otto_handle_cache_compatibility() {
681 # Detect active plugins. SG Optimizer detection now lives in
682 # metasync_otto_disable_sg_page_cache(), which is invoked later
683 # once OTTO confirms suggestions for the URL.
684 $brizy_active = class_exists('Brizy_Editor') || defined('BRIZY_VERSION');
685 $wp_rocket_active = class_exists('WP_Rocket');
686
687 # OPTIMIZED: Get configuration option using cached config
688 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
689
690 # Check for Brizy posts in database
691 global $wpdb;
692 $has_brizy_posts = false;
693
694 if ($brizy_active) {
695 # OPTIMIZED: Check cache first (1-hour TTL) to avoid querying on every page load
696 $cached = get_transient('metasync_has_brizy_posts');
697 if ($cached !== false) {
698 $has_brizy_posts = ($cached === 'yes');
699 } else {
700 # Query database only if cache missed
701 $has_brizy_posts = $wpdb->get_var(
702 "SELECT COUNT(*) FROM {$wpdb->postmeta}
703 WHERE meta_key = 'brizy_post_uid'
704 AND meta_value != ''
705 LIMIT 1"
706 );
707
708 # Cache result for 1 hour
709 $result = !empty($has_brizy_posts) ? 'yes' : 'no';
710 set_transient('metasync_has_brizy_posts', $result, HOUR_IN_SECONDS);
711 }
712 }
713
714 # Determine if DONOTCACHEPAGE should be set
715 $should_set_donotcachepage = false;
716
717 # Case 1: Brizy is active with posts - always needed. Brizy pages are
718 # dynamically rendered regardless of OTTO, so caching must be disabled
719 # whenever Brizy posts exist.
720 if ($brizy_active && !empty($has_brizy_posts)) {
721 $should_set_donotcachepage = true;
722 }
723
724 # Case 2: User explicitly disabled Otto for WP Rocket compatibility
725 elseif ($wp_rocket_active && $wp_rocket_compat_mode === 'disable_otto') {
726 $should_set_donotcachepage = true;
727 return; # Exit early, Otto won't run
728 }
729
730 # Case 3: WP Rocket active with auto/buffer mode - DON'T set DONOTCACHEPAGE
731 # This allows WP Rocket optimizations to continue working
732
733 # NOTE: SiteGround SG Optimizer cache bypass is intentionally NOT
734 # handled here. Emitting no-cache headers / disabling SG cache on this hook
735 # fired on EVERY front-end page — including pages OTTO never modifies —
736 # forcing SG to bypass its page cache site-wide and spiking CPU. The SG
737 # bypass is now deferred to metasync_otto_disable_sg_page_cache(), called
738 # from render_route_html() only after OTTO confirms it has suggestions for
739 # the current URL.
740
741 # Only set DONOTCACHEPAGE if needed
742 if ($should_set_donotcachepage && !defined('DONOTCACHEPAGE')) {
743 define('DONOTCACHEPAGE', true);
744 }
745 }
746
747 /**
748 * Disable SiteGround SG Optimizer page caching for the CURRENT request only.
749 *
750 * Called from Metasync_otto_pixel::render_route_html() once OTTO has confirmed
751 * it has suggestions to apply to the requested URL. Scoping the no-cache
752 * override to pages OTTO actually modifies — rather than emitting it on every
753 * front-end page via the unconditional `wp` hook — keeps SG's page cache
754 * working site-wide and avoids the CPU spike reported in.
755 *
756 * Only meaningful on SiteGround sites without WP Rocket; a no-op otherwise.
757 */
758 function metasync_otto_disable_sg_page_cache() {
759 # Only relevant when SG Optimizer is active and WP Rocket is not present.
760 if (class_exists('WP_Rocket')) {
761 return;
762 }
763 if (!is_plugin_active('sg-cachepress/sg-cachepress.php')) {
764 return;
765 }
766
767 if (!defined('DONOTCACHEPAGE')) {
768 define('DONOTCACHEPAGE', true);
769 }
770 if (!defined('SG_CachePress_SUPERCACHER')) {
771 define('SG_CachePress_SUPERCACHER', false);
772 }
773
774 add_filter('sgo_html_cache_disable', '__return_true', 999);
775 add_filter('sgo_css_combine_exclude', '__return_true', 999);
776 add_filter('sgo_js_combine_exclude', '__return_true', 999);
777 add_filter('sgo_cache_this_page', '__return_false', 999);
778
779 if (!headers_sent()) {
780 header('Cache-Control: no-cache, must-revalidate, max-age=0');
781 header('X-Accel-Expires: 0');
782 }
783 }
784
785 /**
786 * Block SEO plugins conditionally based on what Otto is providing
787 * Only blocks title if Otto has title, only blocks description if Otto has description
788 * This prevents duplicate SEO tags while allowing fallback to SEO plugins when Otto has no data.
789 * Supports Yoast SEO, Rank Math, and AIOSEO (free + pro).
790 *
791 * @param bool $block_title Whether to block title tags.
792 * @param bool $block_description Whether to block description tags.
793 * @param array $description_tags Optional. Granular list of OTTO-provided description tags
794 * (e.g. ['meta[name=description]', 'meta[property=og:description]']).
795 * When provided, only matching AIOSEO tags are suppressed.
796 * When empty, all AIOSEO description tags are suppressed (legacy behavior).
797 */
798 function metasync_otto_block_seo_plugins($block_title = false, $block_description = false, $description_tags = []) {
799 # Disable Yoast SEO (free and premium)
800 if (is_plugin_active('wordpress-seo/wp-seo.php') ||
801 is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {
802
803 # TITLE: Never block Yoast's title output during SSR fetch.
804 # Yoast removes WordPress's native _wp_render_title_tag action and is the sole
805 # renderer of the <title> tag. Returning false/empty from wpseo_title or removing
806 # Title_Presenter leaves the page with NO <title> tag at all — OTTO's buffer
807 # post-processing then has nothing to replace, producing a missing title.
808 # Instead, let Yoast render its own title; OTTO's replace_title() will overwrite
809 # it in the final HTML buffer. deduplicate_title_tags() cleans up any duplicates.
810
811 # Block description only if Otto has description
812 if ($block_description) {
813 add_filter('wpseo_metadesc', '__return_false', 999);
814 add_filter('wpseo_meta_description', '__return_false', 999);
815 add_filter('wpseo_metakeywords', '__return_false', 999);
816 }
817
818 # Block Yoast's modern presenters — description only, never title
819 add_filter('wpseo_frontend_presenters', function($presenters) use ($block_description) {
820 if (!is_array($presenters)) return $presenters;
821
822 $presenters_to_remove = [];
823
824
825 # Remove description presenters only when OTTO has a description
826 if ($block_description) {
827 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Meta_Description_Presenter';
828 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Open_Graph\Description_Presenter';
829 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Twitter\Description_Presenter';
830 }
831
832 foreach ($presenters as $key => $presenter) {
833 // Safely get class name, suppressing autoload errors
834 // This prevents warnings when Composer autoloader tries to load deprecated Yoast files
835 $class_name = is_object($presenter) ? @get_class($presenter) : '';
836
837 if (!empty($class_name) && in_array($class_name, $presenters_to_remove)) {
838 unset($presenters[$key]);
839 }
840 }
841 return $presenters;
842 }, 999);
843 }
844
845 # Disable Rank Math
846 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
847 is_plugin_active('seo-by-rankmath/rank-math.php')) {
848
849 if ($block_title) {
850 add_filter('rank_math/frontend/title', '__return_empty_string', 999);
851 }
852
853 if ($block_description) {
854 add_filter('rank_math/frontend/description', '__return_false', 999);
855 add_filter('rank_math/frontend/show_keywords', '__return_false', 999);
856 }
857 }
858
859 # Disable AIOSEO (free and pro)
860 if (is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
861 is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
862
863 if ($block_title) {
864 add_filter('aioseo_title', '__return_empty_string', 999);
865 add_filter('aioseo_facebook_tags', function($meta) {
866 if (is_array($meta)) { unset($meta['og:title']); }
867 return $meta;
868 }, 999);
869 add_filter('aioseo_twitter_tags', function($meta) {
870 if (is_array($meta)) { unset($meta['twitter:title']); }
871 return $meta;
872 }, 999);
873 }
874
875 if ($block_description) {
876 # Use granular tag list when available to only block what OTTO provides
877 $tags = !empty($description_tags) ? $description_tags : [];
878 $block_standard = empty($tags) || in_array('meta[name=description]', $tags);
879 $block_og_desc = empty($tags) || in_array('meta[property=og:description]', $tags);
880 $block_tw_desc = empty($tags) || in_array('meta[name=twitter:description]', $tags);
881
882 if ($block_standard) {
883 add_filter('aioseo_description', '__return_empty_string', 999);
884 }
885 if ($block_og_desc) {
886 add_filter('aioseo_facebook_tags', function($meta) {
887 if (is_array($meta)) { unset($meta['og:description']); }
888 return $meta;
889 }, 999);
890 }
891 if ($block_tw_desc) {
892 add_filter('aioseo_twitter_tags', function($meta) {
893 if (is_array($meta)) { unset($meta['twitter:description']); }
894 return $meta;
895 }, 999);
896 }
897 }
898 }
899 }
900 # Check whether OTTO is ALSO injected via JavaScript on the site (a misconfiguration
901 # we warn admins about).
902 #
903 # The actual detection makes a loopback HTTP request to the site's OWN url. On hosts
904 # that disallow same-server loopback (e.g. SiteGround), that request blocks for the
905 # full timeout and then fails — and when it runs inline on admin_notices it makes
906 # every wp-admin page hang. It must therefore NEVER run inline on an admin request.
907 #
908 # metasync_check_otto_js() is now read-only: it returns the cached result and, on a
909 # cache miss, schedules a one-off BACKGROUND job (WP-Cron) to compute it. The notice
910 # simply shows nothing until the background result is available.
911 function metasync_check_otto_js(){
912
913 $cache_key = 'metasync_otto_js_detected';
914 $cached = get_transient($cache_key);
915
916 if ($cached !== false) {
917 return $cached === 'yes';
918 }
919
920 # No cached result yet — compute it off the request so we never block admin
921 # with a loopback call. Show nothing this load.
922 metasync_schedule_otto_js_check();
923 return false;
924 };
925
926 # Queue the background loopback detection if it isn't already scheduled.
927 function metasync_schedule_otto_js_check(){
928 if (!wp_next_scheduled('metasync_otto_js_check_event')) {
929 wp_schedule_single_event(time() + 5, 'metasync_otto_js_check_event');
930 }
931 }
932
933 # Background worker (runs in WP-Cron context, NOT on the admin request): performs the
934 # blocking loopback request and caches the result. Hooked to metasync_otto_js_check_event.
935 function metasync_run_otto_js_check(){
936 $cache_key = 'metasync_otto_js_detected';
937
938 # the site url with the internal-fetch marker so OTTO does not re-process it
939 $site_url = site_url() . '?is_otto_page_fetch=1';
940
941 $page_data = wp_remote_get($site_url, array('timeout' => 5, 'sslverify' => false));
942
943 if (is_wp_error($page_data)) {
944 # Loopback failed (host likely blocks same-server requests). Cache "no" so we
945 # don't keep re-queuing the check on every admin page load.
946 set_transient($cache_key, 'no', HOUR_IN_SECONDS);
947 return;
948 }
949
950 $body = wp_remote_retrieve_body($page_data);
951
952 # Detect the OTTO script via a lightweight regex instead of parsing
953 # the entire fetched page into a SimpleHtmlDom tree (which exhausted memory
954 # on large pages — this check runs on admin page loads). We only need to
955 # confirm a <script id="sa-dynamic-optimization" ... data-uuid="..."> exists.
956 $found = preg_match(
957 '/<script\b[^>]*\bid=["\']sa-dynamic-optimization["\'][^>]*\bdata-uuid=["\'][^"\']+["\']/i',
958 (string) $body
959 )
960 || preg_match(
961 # Attribute order may vary (data-uuid before id)
962 '/<script\b[^>]*\bdata-uuid=["\'][^"\']+["\'][^>]*\bid=["\']sa-dynamic-optimization["\']/i',
963 (string) $body
964 );
965
966 if ($found) {
967 set_transient($cache_key, 'yes', 12 * HOUR_IN_SECONDS);
968 return;
969 }
970
971 set_transient($cache_key, 'no', 12 * HOUR_IN_SECONDS);
972 }
973
974 # Register the background worker hook.
975 add_action('metasync_otto_js_check_event', 'metasync_run_otto_js_check');
976
977 # Handle AJAX Clear Cache request
978 # NOTE: Cache system removed - this is now a no-op
979 function metasync_clear_otto_cache_handler() {
980 if (!empty($_GET['clear_otto_cache'])) {
981 delete_transient('metasync_otto_js_detected');
982 # Re-run the JS detection in the background so the notice refreshes.
983 metasync_schedule_otto_js_check();
984 # Cache system has been removed - no cache to clear
985 wp_send_json_success(['message' => 'Cache system removed - all pages processed in real-time']);
986 }
987 else {
988 wp_send_json_error(['message' => 'Missing parameter']);
989 }
990 }
991
992 # Clear cache hook
993 add_action('wp_ajax_metasync_clear_otto_cache', 'metasync_clear_otto_cache_handler');
994
995 # add admin action to check script
996 function metasync_show_otto_ssr_notice() {
997 if (!Metasync::current_user_has_plugin_access()) {
998 return; // Only show to admins
999 }
1000
1001 # Get the plugin name using centralized method
1002 $plugin_name = Metasync::get_effective_plugin_name();
1003 $whitelabel_otto_name = Metasync::get_whitelabel_otto_name();
1004 if (metasync_check_otto_js()) {
1005
1006 # Show admin notice with plugin name included in the message
1007 echo '<div class="notice notice-error">
1008 <p><b>Warning from ' . esc_html($plugin_name) . '</b>
1009 <br>
1010 ' . 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
1011 </p>
1012 </div>';
1013 }
1014 }
1015
1016 add_action('admin_notices', 'metasync_show_otto_ssr_notice');
1017
1018
1019 # staging dummy change
1020 # load otto in the wp hook
1021 add_action('wp', 'metasync_start_otto');
1022
1023 # ENHANCED OTTO SEO INTEGRATION
1024 # Register async SEO processing hook
1025 add_action('metasync_process_seo_job', 'metasync_process_otto_seo_data', 10, 3);
1026 add_action('metasync_process_otto_crawl_url_job', 'metasync_handle_otto_crawl_url_job', 10, 2);
1027 add_action('metasync_process_otto_batch_cache_job', 'metasync_handle_otto_batch_cache_job');
1028
1029 # Process OTTO SEO data and update WordPress meta fields for SEO plugins
1030 # This function now runs asynchronously via WordPress cron system
1031 #
1032 # @param string $route Fully-qualified URL to process.
1033 # @param bool $allow_defer When false, skip CPU-deferral rescheduling (used when
1034 # called synchronously from crawl_url_job whose own retry
1035 # mechanism already handles failures).
1036 # @param int $deferral_count How many times this job has already been deferred for
1037 # CPU load. Prevents infinite reschedule loops.
1038
1039 function metasync_process_otto_seo_data($route, $allow_defer = true, $deferral_count = 0) {
1040 # Maximum number of times a job can be deferred before it is dropped.
1041 $max_deferrals = defined('METASYNC_SEO_JOB_MAX_DEFERRALS') ? METASYNC_SEO_JOB_MAX_DEFERRALS : 5;
1042 $lock_key = null;
1043 $lock_acquired = false;
1044
1045 try {
1046 # Validate input
1047 if (empty($route) || !is_string($route)) {
1048 return false;
1049 }
1050
1051 # CPU load check — defer if server is under load.
1052 # Only reschedule when called from the cron hook (allow_defer=true) and
1053 # we haven't exceeded the maximum deferral count.
1054 # Checked BEFORE acquiring the lock so that deferred jobs don't
1055 # acquire-then-immediately-release it (which defeats concurrency protection).
1056 if (!Metasync_CPU_Monitor::is_load_safe()) {
1057 if ($allow_defer) {
1058 if ($deferral_count < $max_deferrals) {
1059 wp_schedule_single_event(
1060 time() + 60,
1061 'metasync_process_seo_job',
1062 array($route, true, $deferral_count + 1)
1063 );
1064 return false;
1065 }
1066 # deferral budget exhausted — RUN the job late instead
1067 # of dropping it. The write is idempotent and bounded, and a
1068 # late sync is strictly better than SEO data that never
1069 # arrives. No new cron events are created, so the
1070 # anti-pile-up guarantee is preserved. Fall through.
1071 } else {
1072 # allow_defer=false (sync path): return false without creating
1073 # cron events — OTTO's next crawl re-triggers this URL.
1074 return false;
1075 }
1076 }
1077
1078 # Concurrency lock: prevent multiple SEO jobs from running simultaneously.
1079 # Uses a per-URL transient lock with a 120s TTL as a safety net (the lock is
1080 # explicitly deleted on completion). If the lock exists another run is already
1081 # processing this URL — reschedule once with a short delay instead of stacking.
1082 $lock_key = 'metasync_seo_lock_' . md5($route);
1083 if (get_transient($lock_key) !== false) {
1084 if ($allow_defer && $deferral_count < $max_deferrals) {
1085 wp_schedule_single_event(
1086 time() + 30,
1087 'metasync_process_seo_job',
1088 array($route, true, $deferral_count + 1)
1089 );
1090 }
1091 return false;
1092 }
1093 set_transient($lock_key, true, 120);
1094 $lock_acquired = true;
1095
1096 # Resolve redirect table: use final destination URL before 404 checks and OTTO processing
1097 $route = metasync_otto_resolve_redirect_to_final_url($route);
1098
1099 # Skip excluded URLs - don't process SEO data for them
1100 if (metasync_is_otto_url_excluded($route)) {
1101 //error_log('MetaSync OTTO: Skipping SEO processing for excluded URL: ' . $route);
1102 return false;
1103 }
1104
1105 # Pre-flight 404 check: exclude URLs that would return 404 before making API call
1106 if (!metasync_otto_is_url_available($route)) {
1107 error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route}");
1108 metasync_otto_auto_exclude_404_url($route);
1109 return false;
1110 }
1111
1112 # Get OTTO UUID from settings
1113 # OPTIMIZED: Use cached options
1114 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
1115
1116 if (empty($otto_uuid)) {
1117 return false;
1118 }
1119
1120 # Meta descriptions are always enabled by default - no check needed
1121
1122 # Fetch SEO data from OTTO API
1123 $seo_data = metasync_fetch_otto_seo_data($route, $otto_uuid);
1124
1125 if (!$seo_data) {
1126 metasync_record_failed_action( 'metasync_process_seo_job' );
1127 return false;
1128 }
1129
1130 # Mark this URL as crawled by OTTO for SSR
1131 # Extract domain and path from route
1132 $parsed_url = parse_url($route);
1133 $domain_with_scheme = ($parsed_url['scheme'] ?? 'https') . '://' . ($parsed_url['host'] ?? '');
1134 $url_path = ($parsed_url['path'] ?? '/');
1135
1136 # Create crawl data structure
1137 $crawl_data = array(
1138 'domain' => $domain_with_scheme,
1139 'urls' => array($url_path)
1140 );
1141
1142 # Load Otto pixel class and save crawl data
1143 $otto_pixel = new Metasync_otto_pixel($otto_uuid);
1144 $otto_pixel->save_crawl_data($crawl_data);
1145
1146 # Get WordPress post ID from URL
1147 $post_id = url_to_postid($route);
1148
1149 # Special handling for WooCommerce shop page (url_to_postid doesn't work for it)
1150 if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) {
1151 # Check if this URL is the WooCommerce shop page
1152 $shop_page_id = wc_get_page_id('shop');
1153 if ($shop_page_id > 0) {
1154 $shop_url = get_permalink($shop_page_id);
1155 $route_normalized = rtrim($route, '/');
1156 $shop_url_normalized = rtrim($shop_url, '/');
1157
1158 if ($route_normalized === $shop_url_normalized) {
1159 $post_id = $shop_page_id;
1160 }
1161 }
1162 }
1163
1164 # Try to find WooCommerce product by URL if url_to_postid failed
1165 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
1166 # Extract product slug from URL
1167 $product_slug = basename(parse_url($route, PHP_URL_PATH));
1168
1169 # Try to get product by slug
1170 $products = wc_get_products(array(
1171 'name' => $product_slug,
1172 'limit' => 1,
1173 'status' => 'publish',
1174 ));
1175
1176 if (empty($products)) {
1177 # Fallback: try by slug using WP_Query
1178 $args = array(
1179 'post_type' => 'product',
1180 'name' => $product_slug,
1181 'posts_per_page' => 1,
1182 'post_status' => 'publish',
1183 );
1184 $query = new WP_Query($args);
1185
1186 if ($query->have_posts()) {
1187 $product_post = $query->posts[0];
1188 $post_id = $product_post->ID;
1189 }
1190 } else {
1191 $product = $products[0];
1192 $post_id = $product->get_id();
1193 }
1194 }
1195
1196 if (!$post_id || $post_id <= 0) {
1197 # Check if this is a category page
1198 if (strpos($route, '/category/') !== false) {
1199 # Extract category slug from URL
1200 $category_slug = basename(parse_url($route, PHP_URL_PATH));
1201 $category = get_category_by_slug($category_slug);
1202
1203 if ($category) {
1204 # Check if category would return 404 before applying OTTO changes
1205 if (metasync_would_term_return_404($category->term_id, 'category', $route)) {
1206 error_log("MetaSync OTTO: Skipping SEO processing for category that would return 404: {$route} (Category ID: {$category->term_id})");
1207 metasync_otto_auto_exclude_404_url($route);
1208 return false;
1209 }
1210
1211 # Update comprehensive category SEO meta fields
1212 $update_result = metasync_update_comprehensive_category_seo_fields($category->term_id, $seo_data);
1213
1214 if ($update_result['updated']) {
1215 # Prepare trimmed values to 30 characters
1216 $trim = function($value) {
1217 if ($value === null) { return ''; }
1218 $value = (string) $value;
1219 $value = trim($value);
1220 if (mb_strlen($value) > 30) {
1221 return mb_substr($value, 0, 30);
1222 }
1223 return $value;
1224 };
1225
1226 # Log individual field updates for category
1227 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1228 $short = '';
1229 $title = '';
1230
1231 switch ($field_type) {
1232 case 'meta_title':
1233 $short = $trim($field_value);
1234 $title = "Category Meta Title Update ({$short}...)";
1235 break;
1236 case 'meta_description':
1237 $short = $trim($field_value);
1238 $title = "Category Meta Description Update ({$short}...)";
1239 break;
1240 case 'meta_keywords':
1241 $short = $trim($field_value);
1242 $title = "Category Meta Keywords Update ({$short}...)";
1243 break;
1244 case 'og_title':
1245 $short = $trim($field_value);
1246 $title = "Category Open Graph Title Update ({$short}...)";
1247 break;
1248 case 'og_description':
1249 $short = $trim($field_value);
1250 $title = "Category Open Graph Description Update ({$short}...)";
1251 break;
1252 case 'twitter_title':
1253 $short = $trim($field_value);
1254 $title = "Category Twitter Title Update ({$short}...)";
1255 break;
1256 case 'twitter_description':
1257 $short = $trim($field_value);
1258 $title = "Category Twitter Description Update ({$short}...)";
1259 break;
1260 case 'image_alt_data':
1261 $image_count = count($field_value);
1262 $title = "Category Image Alt Text Update ({$image_count} images)";
1263 break;
1264 case 'headings_data':
1265 $heading_count = count($field_value);
1266 $title = "Category Headings Update ({$heading_count} headings)";
1267 break;
1268 case 'structured_data':
1269 $title = 'Category Structured Data Update';
1270 break;
1271 }
1272
1273 if (!empty($title)) {
1274 metasync_log_sync_history([
1275 'title' => $title,
1276 'source' => 'OTTO SEO',
1277 'status' => 'published',
1278 'content_type' => 'Category SEO',
1279 'url' => $route,
1280 'meta_data' => json_encode([
1281 'field' => $field_type,
1282 'field_value' => $field_value,
1283 'category_id' => $category->term_id,
1284 'category_name' => $category->name
1285 ])
1286 ]);
1287 }
1288 }
1289
1290 return true;
1291 }
1292
1293 return false;
1294 }
1295 }
1296
1297 # Check if this is a WooCommerce product category
1298 if (strpos($route, '/product-category/') !== false) {
1299 # Extract product category slug from URL
1300 $category_slug = basename(parse_url($route, PHP_URL_PATH));
1301 $term = get_term_by('slug', $category_slug, 'product_cat');
1302
1303 if ($term && !is_wp_error($term)) {
1304 # Check if product category would return 404 before applying OTTO changes
1305 if (metasync_would_term_return_404($term->term_id, 'product_cat', $route)) {
1306 error_log("MetaSync OTTO: Skipping SEO processing for product category that would return 404: {$route} (Term ID: {$term->term_id})");
1307 metasync_otto_auto_exclude_404_url($route);
1308 return false;
1309 }
1310
1311 # Update comprehensive taxonomy SEO meta fields
1312 $update_result = metasync_update_comprehensive_taxonomy_seo_fields($term->term_id, 'product_cat', $seo_data);
1313
1314 if ($update_result['updated']) {
1315 # Prepare trimmed values to 30 characters
1316 $trim = function($value) {
1317 if ($value === null) { return ''; }
1318 $value = (string) $value;
1319 $value = trim($value);
1320 if (mb_strlen($value) > 30) {
1321 return mb_substr($value, 0, 30);
1322 }
1323 return $value;
1324 };
1325
1326 # Log individual field updates for product category
1327 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1328 $short = '';
1329 $title = '';
1330
1331 switch ($field_type) {
1332 case 'meta_title':
1333 $short = $trim($field_value);
1334 $title = "Product Category Meta Title Update ({$short}...)";
1335 break;
1336 case 'meta_description':
1337 $short = $trim($field_value);
1338 $title = "Product Category Meta Description Update ({$short}...)";
1339 break;
1340 case 'meta_keywords':
1341 $short = $trim($field_value);
1342 $title = "Product Category Meta Keywords Update ({$short}...)";
1343 break;
1344 case 'og_title':
1345 $short = $trim($field_value);
1346 $title = "Product Category Open Graph Title Update ({$short}...)";
1347 break;
1348 case 'og_description':
1349 $short = $trim($field_value);
1350 $title = "Product Category Open Graph Description Update ({$short}...)";
1351 break;
1352 case 'twitter_title':
1353 $short = $trim($field_value);
1354 $title = "Product Category Twitter Title Update ({$short}...)";
1355 break;
1356 case 'twitter_description':
1357 $short = $trim($field_value);
1358 $title = "Product Category Twitter Description Update ({$short}...)";
1359 break;
1360 case 'image_alt_data':
1361 $image_count = count($field_value);
1362 $title = "Product Category Image Alt Text Update ({$image_count} images)";
1363 break;
1364 case 'headings_data':
1365 $heading_count = count($field_value);
1366 $title = "Product Category Headings Update ({$heading_count} headings)";
1367 break;
1368 case 'structured_data':
1369 $title = 'Product Category Structured Data Update';
1370 break;
1371 }
1372
1373 if (!empty($title)) {
1374 metasync_log_sync_history([
1375 'title' => $title,
1376 'source' => 'OTTO SEO',
1377 'status' => 'published',
1378 'content_type' => 'WooCommerce Product Category SEO',
1379 'url' => $route,
1380 'meta_data' => json_encode([
1381 'field' => $field_type,
1382 'field_value' => $field_value,
1383 'term_id' => $term->term_id,
1384 'term_name' => $term->name,
1385 'taxonomy' => 'product_cat'
1386 ])
1387 ]);
1388 }
1389 }
1390
1391 return true;
1392 }
1393
1394 return false;
1395 }
1396 }
1397
1398 # Check if this is the home page (landing page)
1399 $site_url = rtrim(site_url(), '/');
1400 $route_clean = rtrim($route, '/');
1401
1402 if ($route_clean === $site_url) {
1403 # Get the home page (front page)
1404 $front_page_id = get_option('page_on_front');
1405 $home_page = null;
1406
1407 if ($front_page_id && $front_page_id > 0) {
1408 $home_page = get_post($front_page_id);
1409 } else {
1410 # If no static front page is set, get the latest post
1411 $home_page = get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null;
1412 }
1413
1414 if ($home_page) {
1415 # Check if home page would return 404 before applying OTTO changes
1416 if (metasync_would_page_return_404($home_page->ID, $route)) {
1417 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})");
1418 metasync_otto_auto_exclude_404_url($route);
1419 return false;
1420 }
1421
1422 # Update comprehensive home page SEO meta fields
1423 $update_result = metasync_update_comprehensive_seo_fields($home_page->ID, $seo_data);
1424
1425 if ($update_result['updated']) {
1426 # Clear relevant caches
1427 metasync_clear_post_seo_caches($home_page->ID);
1428
1429 # Prepare trimmed values to 30 characters
1430 $trim = function($value) {
1431 if ($value === null) { return ''; }
1432 $value = (string) $value;
1433 $value = trim($value);
1434 if (mb_strlen($value) > 30) {
1435 return mb_substr($value, 0, 30);
1436 }
1437 return $value;
1438 };
1439
1440 # Log individual field updates for home page
1441 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1442 $short = '';
1443 $title = '';
1444
1445 switch ($field_type) {
1446 case 'meta_title':
1447 $short = $trim($field_value);
1448 $title = "Home Page Meta Title Update ({$short}...)";
1449 break;
1450 case 'meta_description':
1451 $short = $trim($field_value);
1452 $title = "Home Page Meta Description Update ({$short}...)";
1453 break;
1454 case 'meta_keywords':
1455 $short = $trim($field_value);
1456 $title = "Home Page Meta Keywords Update ({$short}...)";
1457 break;
1458 case 'og_title':
1459 $short = $trim($field_value);
1460 $title = "Home Page Open Graph Title Update ({$short}...)";
1461 break;
1462 case 'og_description':
1463 $short = $trim($field_value);
1464 $title = "Home Page Open Graph Description Update ({$short}...)";
1465 break;
1466 case 'twitter_title':
1467 $short = $trim($field_value);
1468 $title = "Home Page Twitter Title Update ({$short}...)";
1469 break;
1470 case 'twitter_description':
1471 $short = $trim($field_value);
1472 $title = "Home Page Twitter Description Update ({$short}...)";
1473 break;
1474 case 'image_alt_data':
1475 $image_count = count($field_value);
1476 $title = "Home Page Image Alt Text Update ({$image_count} images)";
1477 break;
1478 case 'headings_data':
1479 $heading_count = count($field_value);
1480 $title = "Home Page Headings Update ({$heading_count} headings)";
1481 break;
1482 case 'structured_data':
1483 $title = 'Home Page Structured Data Update';
1484 break;
1485 }
1486
1487 if (!empty($title)) {
1488 metasync_log_sync_history([
1489 'title' => $title,
1490 'source' => 'OTTO SEO',
1491 'status' => 'published',
1492 'content_type' => 'Home Page SEO',
1493 'url' => $route,
1494 'meta_data' => json_encode([
1495 'field' => $field_type,
1496 'field_value' => $field_value,
1497 'post_id' => $home_page->ID
1498 ])
1499 ]);
1500 }
1501 }
1502
1503 return true;
1504 }
1505
1506 return false;
1507 }
1508 }
1509
1510 # Check if this is the blog/posts page (page_for_posts)
1511 $posts_page_id = intval(get_option('page_for_posts'));
1512 if ($posts_page_id > 0) {
1513 $posts_page = get_post($posts_page_id);
1514 $posts_page_url = rtrim(get_permalink($posts_page_id), '/');
1515
1516 if ($posts_page && $route_clean === $posts_page_url) {
1517 # Check if blog page would return 404
1518 if (metasync_would_page_return_404($posts_page->ID, $route)) {
1519 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})");
1520 metasync_otto_auto_exclude_404_url($route);
1521 return false;
1522 }
1523
1524 # Update comprehensive blog page SEO meta fields
1525 $update_result = metasync_update_comprehensive_seo_fields($posts_page->ID, $seo_data);
1526
1527 if ($update_result['updated']) {
1528 # Clear relevant caches
1529 metasync_clear_post_seo_caches($posts_page->ID);
1530
1531 # Prepare trimmed values to 30 characters
1532 $trim = function($value) {
1533 if ($value === null) { return ''; }
1534 $value = (string) $value;
1535 $value = trim($value);
1536 if (mb_strlen($value) > 30) {
1537 return mb_substr($value, 0, 30);
1538 }
1539 return $value;
1540 };
1541
1542 # Log individual field updates for blog page
1543 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1544 $short = '';
1545 $title = '';
1546
1547 switch ($field_type) {
1548 case 'meta_title':
1549 $short = $trim($field_value);
1550 $title = "Blog Page Meta Title Update ({$short}...)";
1551 break;
1552 case 'meta_description':
1553 $short = $trim($field_value);
1554 $title = "Blog Page Meta Description Update ({$short}...)";
1555 break;
1556 case 'meta_keywords':
1557 $short = $trim($field_value);
1558 $title = "Blog Page Meta Keywords Update ({$short}...)";
1559 break;
1560 case 'og_title':
1561 $short = $trim($field_value);
1562 $title = "Blog Page Open Graph Title Update ({$short}...)";
1563 break;
1564 case 'og_description':
1565 $short = $trim($field_value);
1566 $title = "Blog Page Open Graph Description Update ({$short}...)";
1567 break;
1568 case 'twitter_title':
1569 $short = $trim($field_value);
1570 $title = "Blog Page Twitter Title Update ({$short}...)";
1571 break;
1572 case 'twitter_description':
1573 $short = $trim($field_value);
1574 $title = "Blog Page Twitter Description Update ({$short}...)";
1575 break;
1576 case 'image_alt_data':
1577 $image_count = count($field_value);
1578 $title = "Blog Page Image Alt Text Update ({$image_count} images)";
1579 break;
1580 case 'headings_data':
1581 $heading_count = count($field_value);
1582 $title = "Blog Page Headings Update ({$heading_count} headings)";
1583 break;
1584 case 'structured_data':
1585 $title = 'Blog Page Structured Data Update';
1586 break;
1587 }
1588
1589 if (!empty($title)) {
1590 metasync_log_sync_history([
1591 'title' => $title,
1592 'source' => 'OTTO SEO',
1593 'status' => 'published',
1594 'content_type' => 'Blog Page SEO',
1595 'url' => $route,
1596 'meta_data' => json_encode([
1597 'field' => $field_type,
1598 'field_value' => $field_value,
1599 'post_id' => $posts_page->ID
1600 ])
1601 ]);
1602 }
1603 }
1604
1605 return true;
1606 }
1607
1608 return false;
1609 }
1610 }
1611
1612 # URL didn't resolve to any supported entity (post, category, home page, blog page)
1613 # Treat as 404 and auto-exclude (e.g. deleted post, non-existent page)
1614 if (!metasync_otto_is_url_available($route)) {
1615 error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404 (no matching entity): {$route}");
1616 metasync_otto_auto_exclude_404_url($route);
1617 }
1618 return false;
1619 }
1620
1621 # Verify this is actually a post, page, or WooCommerce product
1622 $post = get_post($post_id);
1623
1624 # Get supported post types dynamically
1625 $supported_post_types = metasync_get_supported_post_types();
1626
1627 if (!$post || !in_array($post->post_type, $supported_post_types)) {
1628 # Skip unsupported post types
1629 return false;
1630 }
1631
1632 # Check if page would return 404 before applying OTTO changes
1633 if (metasync_would_page_return_404($post_id, $route)) {
1634 error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route} (Post ID: {$post_id}, Status: {$post->post_status})");
1635 metasync_otto_auto_exclude_404_url($route);
1636 return false;
1637 }
1638
1639 # Update comprehensive SEO meta fields
1640 $update_result = metasync_update_comprehensive_seo_fields($post_id, $seo_data);
1641
1642 if ($update_result['updated']) {
1643 # Clear relevant caches
1644 metasync_clear_post_seo_caches($post_id);
1645
1646 # Prepare trimmed values to 30 characters
1647 $trim = function($value) {
1648 if ($value === null) { return ''; }
1649 $value = (string) $value;
1650 $value = trim($value);
1651 if (mb_strlen($value) > 30) {
1652 return mb_substr($value, 0, 30);
1653 }
1654 return $value;
1655 };
1656
1657 # Log individual field updates
1658 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1659 $short = '';
1660 $title = '';
1661
1662 switch ($field_type) {
1663 case 'meta_title':
1664 $short = $trim($field_value);
1665 $title = 'Meta Title Update (' . $short . '...)';
1666 break;
1667 case 'meta_description':
1668 $short = $trim($field_value);
1669 $title = 'Meta Description Update (' . $short . '...)';
1670 break;
1671 case 'meta_keywords':
1672 $short = $trim($field_value);
1673 $title = 'Meta Keywords Update (' . $short . '...)';
1674 break;
1675 case 'og_title':
1676 $short = $trim($field_value);
1677 $title = 'Open Graph Title Update (' . $short . '...)';
1678 break;
1679 case 'og_description':
1680 $short = $trim($field_value);
1681 $title = 'Open Graph Description Update (' . $short . '...)';
1682 break;
1683 case 'twitter_title':
1684 $short = $trim($field_value);
1685 $title = 'Twitter Title Update (' . $short . '...)';
1686 break;
1687 case 'twitter_description':
1688 $short = $trim($field_value);
1689 $title = 'Twitter Description Update (' . $short . '...)';
1690 break;
1691 case 'image_alt_data':
1692 $image_count = count($field_value);
1693 $title = "Image Alt Text Update ({$image_count} images)";
1694 break;
1695 case 'headings_data':
1696 $heading_count = count($field_value);
1697 $title = "Headings Update ({$heading_count} headings)";
1698 break;
1699 case 'structured_data':
1700 $title = 'Structured Data Update';
1701 break;
1702 }
1703
1704 if (!empty($title)) {
1705 metasync_log_sync_history([
1706 'title' => $title,
1707 'source' => 'OTTO SEO',
1708 'status' => 'published',
1709 'content_type' => 'SEO Meta',
1710 'url' => $route,
1711 'meta_data' => json_encode([
1712 'field' => $field_type,
1713 'field_value' => $field_value,
1714 'post_id' => $post_id
1715 ])
1716 ]);
1717 }
1718 }
1719
1720 return true;
1721 }
1722
1723 return false;
1724
1725 } catch (Exception $e) {
1726 metasync_record_failed_action( 'metasync_process_seo_job' );
1727 return false;
1728 } finally {
1729 # Release the concurrency lock only if we actually acquired it.
1730 # Early returns (CPU deferral, lock contention) must NOT delete a lock
1731 # that another process may be holding.
1732 if ($lock_acquired && $lock_key) {
1733 delete_transient($lock_key);
1734 }
1735 }
1736 }
1737
1738 /**
1739 * Log sync history entry
1740 * @param array $data Sync data to log
1741 */
1742 function metasync_log_sync_history($data) {
1743 try {
1744 // Classes are now autoloaded, no need for manual require
1745 $sync_db = new Metasync_Sync_History_Database();
1746
1747 // Minimal duplicate prevention within short time window
1748 if (!empty($data['title']) && !empty($data['source'])) {
1749 global $wpdb;
1750 $table = $wpdb->prefix . Metasync_Sync_History_Database::$table_name;
1751 $recent = $wpdb->get_var($wpdb->prepare(
1752 "SELECT COUNT(*) FROM `$table` WHERE title = %s AND source = %s AND created_at >= %s",
1753 $data['title'],
1754 $data['source'],
1755 gmdate('Y-m-d H:i:s', time() - 60)
1756 ));
1757 if ((int)$recent > 0) {
1758 return; // skip duplicate log within 60 seconds
1759 }
1760 }
1761
1762 $sync_db->add($data);
1763
1764 } catch (Exception $e) {
1765 error_log("MetaSync: Failed to log sync history: " . $e->getMessage());
1766 }
1767 }
1768
1769 /**
1770 * Get supported post types for OTTO SEO optimization
1771 * Includes WooCommerce products if WooCommerce is active
1772 *
1773 * @return array List of supported post types
1774 */
1775 function metasync_get_supported_post_types() {
1776 # Start with default post types
1777 $post_types = ['post', 'page'];
1778
1779 # Add WooCommerce product post type if WooCommerce is active
1780 if (class_exists('WooCommerce') || function_exists('is_woocommerce')) {
1781 $post_types[] = 'product';
1782 }
1783
1784 # Include all public custom post types (e.g. 'location', 'service', 'team', etc.)
1785 # so OTTO can write post meta for them during metasync_process_otto_seo_data().
1786 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
1787 if (!empty($custom_post_types)) {
1788 $post_types = array_merge($post_types, array_values($custom_post_types));
1789 }
1790
1791 # Allow developers to filter supported post types
1792 $post_types = apply_filters('metasync_otto_supported_post_types', $post_types);
1793
1794 return $post_types;
1795 }
1796
1797 /**
1798 * Get supported taxonomies for OTTO SEO optimization
1799 * Includes WooCommerce product categories and tags if WooCommerce is active
1800 *
1801 * @return array List of supported taxonomies
1802 */
1803 function metasync_get_supported_taxonomies() {
1804 # Start with default taxonomies
1805 $taxonomies = ['category'];
1806
1807 # Add WooCommerce taxonomies if WooCommerce is active
1808 if (class_exists('WooCommerce') || function_exists('is_woocommerce')) {
1809 $taxonomies[] = 'product_cat'; # WooCommerce product categories
1810 $taxonomies[] = 'product_tag'; # WooCommerce product tags
1811 }
1812
1813 # Allow developers to filter supported taxonomies
1814 $taxonomies = apply_filters('metasync_otto_supported_taxonomies', $taxonomies);
1815
1816 return $taxonomies;
1817 }
1818
1819 /**
1820 * Resolve a URL through the Redirect Manager table to its final destination (follows redirect chains).
1821 * Used before 404 checks and OTTO processing so the final canonical URL is used, not intermediate redirects.
1822 *
1823 * @param string $url Full URL (e.g. https://example.com/old-page)
1824 * @return string Final destination URL, or original $url if no redirect matches
1825 */
1826 function metasync_otto_resolve_redirect_to_final_url($url)
1827 {
1828 if (empty($url) || !is_string($url)) {
1829 return $url;
1830 }
1831 try {
1832 $db_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection-database.php';
1833 $class_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection.php';
1834 if (!file_exists($db_path) || !file_exists($class_path)) {
1835 return $url;
1836 }
1837 require_once $db_path;
1838 require_once $class_path;
1839 $db = new Metasync_Redirection_Database();
1840 $redirect = new Metasync_Redirection($db);
1841 return $redirect->resolve_url_to_final_destination($url, 10);
1842 } catch (Exception $e) {
1843 error_log('MetaSync OTTO: Redirect resolution failed for ' . $url . ' - ' . $e->getMessage());
1844 return $url;
1845 }
1846 }
1847
1848 /**
1849 * Auto-exclude a URL from OTTO with description "Auto-excluded: 404"
1850 * Called when a URL is detected as returning 404 so it won't be sent to OTTO again
1851 *
1852 * @param string $url Full URL to exclude (e.g. https://example.com/404-page)
1853 * @return bool|string True on success, false on failure, 'duplicate'/'reactivated' if already exists
1854 */
1855 function metasync_otto_auto_exclude_404_url($url)
1856 {
1857 if (empty($url) || !is_string($url)) {
1858 return false;
1859 }
1860 $url = filter_var($url, FILTER_SANITIZE_URL);
1861 $url = esc_url_raw($url);
1862 if (empty($url) || mb_strlen($url) > 2048) {
1863 return false;
1864 }
1865 try {
1866 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1867 $db = new Metasync_Otto_Excluded_URLs_Database();
1868 return $db->add([
1869 'url_pattern' => $url,
1870 'pattern_type' => 'exact',
1871 'description' => 'Auto-excluded: 404',
1872 'status' => 'active',
1873 'auto_excluded' => 1,
1874 ]);
1875 } catch (Exception $e) {
1876 error_log('MetaSync OTTO: Failed to auto-exclude 404 URL: ' . $url . ' - ' . $e->getMessage());
1877 return false;
1878 }
1879 }
1880
1881 /**
1882 * Remove a URL from the OTTO auto-exclusion list.
1883 * Called when OTTO sends a webhook for a URL, confirming it is valid and crawlable.
1884 * Only removes records where auto_excluded = 1 (never removes manual exclusions).
1885 *
1886 * @param string $url Full URL to un-exclude (e.g. https://example.com/location/page)
1887 * @return bool True on success
1888 */
1889 function metasync_otto_remove_auto_exclusion($url)
1890 {
1891 if (empty($url) || !is_string($url)) {
1892 return false;
1893 }
1894 try {
1895 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1896 $db = new Metasync_Otto_Excluded_URLs_Database();
1897 global $wpdb;
1898 $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
1899 # Normalize the same way is_url_excluded() does
1900 $url_normalized = rtrim(trim($url), '/');
1901 $records = $wpdb->get_results(
1902 $wpdb->prepare(
1903 "SELECT id FROM `{$table}` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'",
1904 $url_normalized
1905 )
1906 );
1907 if (!empty($records)) {
1908 $ids = array_map(function ($r) { return (int) $r->id; }, $records);
1909 $db->delete($ids);
1910 }
1911 return true;
1912 } catch (Exception $e) {
1913 return false;
1914 }
1915 }
1916
1917 /**
1918 * Check if a URL is MANUALLY excluded from OTTO (auto_excluded = 0).
1919 * Used at render time (metasync_start_otto) — auto-exclusions must NOT block
1920 * rendering because they are often false positives (e.g. custom post types that
1921 * url_to_postid() can't resolve). Auto-exclusions are only used to gate the
1922 * SEO meta-writing webhook path.
1923 *
1924 * @param string $url URL to check
1925 * @return bool True if URL has a manual exclusion
1926 */
1927 function metasync_is_otto_url_manually_excluded($url)
1928 {
1929 if (empty($url) || !is_string($url)) {
1930 return false;
1931 }
1932 try {
1933 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1934 global $wpdb;
1935 $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
1936 $url_normalized = rtrim(trim($url), '/');
1937
1938 $records = get_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY);
1939
1940 if ($records === false) {
1941 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- no user input, table name from $wpdb->prefix
1942 $records = $wpdb->get_results(
1943 "SELECT url_pattern, pattern_type FROM `{$table}` WHERE status = 'active' AND (auto_excluded = 0 OR auto_excluded IS NULL) ORDER BY created_at DESC"
1944 );
1945
1946 // Graceful recovery: auto_excluded column missing on pre-v2.7.4 installs.
1947 // Run ALTER TABLE to add it and treat URL as not excluded so OTTO continues rendering.
1948 if ($wpdb->last_error && strpos($wpdb->last_error, 'auto_excluded') !== false) {
1949 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1950 $wpdb->query("ALTER TABLE `{$table}` ADD COLUMN `auto_excluded` TINYINT(1) NOT NULL DEFAULT 0");
1951 return false;
1952 }
1953
1954 set_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY, $records ?: [], METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL);
1955 }
1956
1957 if (empty($records)) {
1958 return false;
1959 }
1960
1961 foreach ($records as $excluded) {
1962 $pattern = rtrim(trim($excluded->url_pattern), '/');
1963 $pattern_type = $excluded->pattern_type;
1964
1965 switch ($pattern_type) {
1966 case 'exact':
1967 if ($url_normalized === $pattern) {
1968 return true;
1969 }
1970 break;
1971 case 'contain':
1972 if (strpos($url_normalized, $pattern) !== false) {
1973 return true;
1974 }
1975 break;
1976 case 'start':
1977 if (strpos($url_normalized, $pattern) === 0) {
1978 return true;
1979 }
1980 break;
1981 }
1982 }
1983 return false;
1984 } catch (Exception $e) {
1985 return false;
1986 }
1987 }
1988
1989 /**
1990 * Check if a URL is excluded from OTTO
1991 * @param string $url URL to check
1992 * @return bool True if URL is excluded, false otherwise
1993 */
1994 function metasync_is_otto_url_excluded($url)
1995 {
1996 try {
1997 // Load database class
1998 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1999 $db = new Metasync_Otto_Excluded_URLs_Database();
2000
2001 // Check if URL is excluded
2002 return $db->is_url_excluded($url);
2003
2004 } catch (Exception $e) {
2005 return false;
2006 }
2007 }
2008
2009 /**
2010 * Check if a URL is now available (would NOT return 404)
2011 * Uses same resolution logic as metasync_process_otto_seo_data
2012 * Used when rechecking auto-excluded 404 URLs after 7 days
2013 *
2014 * @param string $url Full URL to check (e.g. https://example.com/page)
2015 * @return bool True if URL is accessible, false if it would return 404
2016 */
2017 function metasync_otto_is_url_available($url)
2018 {
2019 if (empty($url) || !is_string($url)) {
2020 return false;
2021 }
2022
2023 $route = metasync_otto_resolve_redirect_to_final_url($url);
2024 $post_id = url_to_postid($route);
2025
2026 # WooCommerce shop page
2027 if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) {
2028 $shop_page_id = wc_get_page_id('shop');
2029 if ($shop_page_id > 0) {
2030 $shop_url = get_permalink($shop_page_id);
2031 if (rtrim($route, '/') === rtrim($shop_url, '/')) {
2032 $post_id = $shop_page_id;
2033 }
2034 }
2035 }
2036
2037 # WooCommerce product by slug
2038 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
2039 $product_slug = basename(parse_url($route, PHP_URL_PATH));
2040 $products = wc_get_products(array('name' => $product_slug, 'limit' => 1, 'status' => 'publish'));
2041 if (!empty($products)) {
2042 $post_id = $products[0]->get_id();
2043 } else {
2044 $query = new WP_Query(array(
2045 'post_type' => 'product',
2046 'name' => $product_slug,
2047 'posts_per_page' => 1,
2048 'post_status' => 'publish',
2049 ));
2050 if ($query->have_posts()) {
2051 $post_id = $query->posts[0]->ID;
2052 }
2053 }
2054 }
2055
2056 if ($post_id && $post_id > 0) {
2057 $post = get_post($post_id);
2058 # Accept any post type — custom post types (e.g. 'location', 'service') are valid URLs.
2059 # The old in_array check against metasync_get_supported_post_types() caused CPT URLs
2060 # to be wrongly auto-excluded as "404" pages.
2061 if ($post) {
2062 return !metasync_would_page_return_404($post_id, $route);
2063 }
2064 }
2065
2066 # Category
2067 if (strpos($route, '/category/') !== false) {
2068 $category_slug = basename(parse_url($route, PHP_URL_PATH));
2069 $category = get_category_by_slug($category_slug);
2070 if ($category) {
2071 return !metasync_would_term_return_404($category->term_id, 'category', $route);
2072 }
2073 }
2074
2075 # WooCommerce product category
2076 if (strpos($route, '/product-category/') !== false) {
2077 $category_slug = basename(parse_url($route, PHP_URL_PATH));
2078 $term = get_term_by('slug', $category_slug, 'product_cat');
2079 if ($term && !is_wp_error($term)) {
2080 return !metasync_would_term_return_404($term->term_id, 'product_cat', $route);
2081 }
2082 }
2083
2084 # Home page
2085 if (rtrim($route, '/') === rtrim(site_url(), '/')) {
2086 $front_page_id = get_option('page_on_front');
2087 $home_page = ($front_page_id && $front_page_id > 0)
2088 ? get_post($front_page_id)
2089 : (get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null);
2090 if ($home_page) {
2091 return !metasync_would_page_return_404($home_page->ID, $route);
2092 }
2093 }
2094
2095 # Could not verify availability from local data (custom archive, paginated page, etc.).
2096 # Assume the URL IS available — OTTO only crawls reachable URLs, so if we can't
2097 # prove it's a 404, we should not auto-exclude it.
2098 return true;
2099 }
2100
2101 /**
2102 * Recheck auto-excluded 404 URLs when recheck_after has passed; remove from exclusion if now available
2103 * Uses recheck_after timestamp (default 7 days from exclusion) to decide when to recheck
2104 * Mark as permanent after 30 days if still 404 (no further rechecks)
2105 * Called by daily cron job
2106 */
2107 function metasync_otto_recheck_404_exclusions()
2108 {
2109 try {
2110 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2111 $db = new Metasync_Otto_Excluded_URLs_Database();
2112 $records = $db->get_auto_excluded_404_urls_due_for_recheck();
2113
2114 if (empty($records)) {
2115 return;
2116 }
2117
2118 $removed = 0;
2119 $marked_permanent = 0;
2120 $thirty_days_ago = strtotime('-30 days');
2121 $next_recheck = date('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS);
2122
2123 foreach ($records as $record) {
2124 $url = trim($record->url_pattern);
2125 if (empty($url)) {
2126 continue;
2127 }
2128 if (metasync_otto_is_url_available($url)) {
2129 $db->delete([$record->id]);
2130 $removed++;
2131 } else {
2132 # Still 404: if excluded 30+ days ago, mark as permanent (no more rechecks)
2133 $created_ts = strtotime($record->created_at);
2134 if ($created_ts <= $thirty_days_ago) {
2135 $db->update(['is_permanent' => 1], $record->id);
2136 $marked_permanent++;
2137 } else {
2138 # Schedule next recheck in 7 days
2139 $db->update(['recheck_after' => $next_recheck], $record->id);
2140 }
2141 }
2142 }
2143
2144 if ($removed > 0) {
2145 error_log("MetaSync OTTO: Recheck 404 exclusions - removed {$removed} URL(s) that are now available");
2146 }
2147 if ($marked_permanent > 0) {
2148 error_log("MetaSync OTTO: Recheck 404 exclusions - marked {$marked_permanent} URL(s) as permanent (still 404 after 30 days)");
2149 }
2150 } catch (Exception $e) {
2151 error_log('MetaSync OTTO: Recheck 404 exclusions failed - ' . $e->getMessage());
2152 }
2153 }
2154
2155 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
2156
2157 /**
2158 * Check if a post/page would return 404 without making HTTP request
2159 * Uses WordPress database checks for fast validation
2160 *
2161 * @param int $post_id WordPress post ID
2162 * @param string $url The URL being checked (optional, for logging)
2163 * @return bool True if page would return 404, false if accessible
2164 */
2165 function metasync_would_page_return_404($post_id, $url = '') {
2166 if (!$post_id || $post_id <= 0) {
2167 return true; // No post ID = 404
2168 }
2169
2170 # Get the post object
2171 $post = get_post($post_id);
2172 if (!$post) {
2173 return true; // Post doesn't exist = 404
2174 }
2175
2176 # 1. Check post status - must be 'publish' to be publicly accessible
2177 if ($post->post_status !== 'publish') {
2178 return true; // Draft, pending, private, etc. = 404
2179 }
2180
2181 # 2. Check if post is password protected (requires password to view)
2182 if (!empty($post->post_password)) {
2183 # Password protected posts are not publicly accessible without password
2184 return true; // Password protected = effectively 404 for public
2185 }
2186
2187 # 3. Check if post is in trash
2188 if ($post->post_status === 'trash') {
2189 return true; // Trashed = 404
2190 }
2191
2192 # 4. Check if post type is publicly queryable
2193 $post_type_object = get_post_type_object($post->post_type);
2194 if ($post_type_object && !$post_type_object->publicly_queryable) {
2195 # Some post types might not be publicly accessible
2196 # But we allow if it's in our supported types
2197 $supported_post_types = metasync_get_supported_post_types();
2198 if (!in_array($post->post_type, $supported_post_types)) {
2199 return true; // Not publicly queryable = 404
2200 }
2201 }
2202
2203 # 5. WordPress 5.7+ has a built-in function for this
2204 if (function_exists('is_post_publicly_viewable')) {
2205 if (!is_post_publicly_viewable($post)) {
2206 return true; // Not publicly viewable = 404
2207 }
2208 }
2209
2210 # 6. Check if post is scheduled for future (not yet published)
2211 if ($post->post_date > current_time('mysql')) {
2212 return true; // Future post = 404 until publish date
2213 }
2214
2215 # All checks passed - page should be accessible
2216 return false;
2217 }
2218
2219 /**
2220 * Check if a taxonomy term (category, tag, etc.) would return 404
2221 * Uses WordPress database checks for fast validation
2222 *
2223 * @param int $term_id Term ID
2224 * @param string $taxonomy Taxonomy name (e.g., 'category', 'product_cat')
2225 * @param string $url The URL being checked (optional, for logging)
2226 * @return bool True if term would return 404, false if accessible
2227 */
2228 function metasync_would_term_return_404($term_id, $taxonomy, $url = '') {
2229 if (!$term_id || $term_id <= 0 || empty($taxonomy)) {
2230 return true; // Invalid term = 404
2231 }
2232
2233 # Get the term object
2234 $term = get_term($term_id, $taxonomy);
2235 if (is_wp_error($term) || !$term) {
2236 return true; // Term doesn't exist = 404
2237 }
2238
2239 # Check if taxonomy is publicly queryable
2240 $taxonomy_object = get_taxonomy($taxonomy);
2241 if (!$taxonomy_object || !$taxonomy_object->public) {
2242 # Check if it's in our supported taxonomies
2243 $supported_taxonomies = metasync_get_supported_taxonomies();
2244 if (!in_array($taxonomy, $supported_taxonomies)) {
2245 return true; // Not publicly queryable = 404
2246 }
2247 }
2248
2249 # Terms are generally always accessible if they exist and taxonomy is public
2250 # WordPress doesn't have a "draft" status for terms like posts do
2251 # But we can check if the term has a count (has posts assigned)
2252 # Empty terms might not be useful, but they're still accessible
2253
2254 # All checks passed - term should be accessible
2255 return false;
2256 }
2257
2258 /**
2259 * Invalidate Brizy posts cache when posts are saved
2260 * OPTIMIZATION: Clears transient cache to ensure accurate detection
2261 */
2262 add_action('save_post', function($post_id) {
2263 # Check if this post has Brizy metadata
2264 if (get_post_meta($post_id, 'brizy_post_uid', true)) {
2265 delete_transient('metasync_has_brizy_posts');
2266 }
2267 }, 10, 1);
2268
2269 /**
2270 * Identify infrastructure agents that must never hit the render throttle.
2271 *
2272 * Two classes of agent are covered:
2273 *
2274 * - Page-cache warmers. A host preloader (WP Cloud PageCacheBot, WP Rocket preload,
2275 * LiteSpeed, SG Optimizer, ...) exists purely to populate the page cache. Throttling
2276 * one returns un-OTTO'd HTML *and* defines DONOTCACHEPAGE, so the cache can never be
2277 * filled and every subsequent human visitor pays a full uncached render. Measured on
2278 * the reporting site: desktop cache MISS on 17/17 requests at ~2.8s TTFB, versus
2279 * ~0.56s with OTTO disabled.
2280 *
2281 * - Synthetic performance auditors (Lighthouse / PageSpeed Insights / GTmetrix).
2282 * These are what customers measure with, so they must receive the same fully
2283 * rendered OTTO output a real visitor gets. Currently `/lighthouse/i` and
2284 * `/pagespeed/i` are generic-bot patterns, so a second PSI run inside the 5-minute
2285 * window is served the throttled, un-OTTO'd, uncacheable page.
2286 *
2287 * Matching is done on the raw user-agent, deliberately independent of the bot-name
2288 * categorisation in Metasync_Otto_Bot_Detector.
2289 *
2290 * @param mixed $detection Result of Metasync_Otto_Bot_Detector::detect(), or malformed input.
2291 * @return bool True when this request must bypass the render throttle.
2292 */
2293 function metasync_otto_is_unthrottled_infrastructure_agent( $detection = array() ) {
2294
2295 $ua = '';
2296 if ( is_array( $detection ) && ! empty( $detection['user_agent'] ) ) {
2297 $ua = (string) $detection['user_agent'];
2298 } elseif ( isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
2299 $ua = (string) $_SERVER['HTTP_USER_AGENT'];
2300 }
2301
2302 $needles = array(
2303 // Page-cache preloaders.
2304 'pagecachebot', // WP Cloud / Automattic (PageCacheBotDesktop|Mobile)
2305 'wp rocket', // WP Rocket preload
2306 'wprocket',
2307 'lscache_runner', // LiteSpeed Cache crawler
2308 'sg-optimizer', // SiteGround
2309 'sgoptimizer',
2310 'nitropack',
2311 'cache-warmer',
2312 'cachewarmer',
2313 'cache warmer',
2314 // Synthetic performance auditors.
2315 'lighthouse',
2316 'pagespeed',
2317 'gtmetrix',
2318 'pingdom',
2319 'webpagetest',
2320 );
2321
2322 $is_infra = false;
2323
2324 if ( '' !== $ua ) {
2325 $ua_lower = strtolower( $ua );
2326 foreach ( $needles as $needle ) {
2327 if ( false !== strpos( $ua_lower, $needle ) ) {
2328 $is_infra = true;
2329 break;
2330 }
2331 }
2332 }
2333
2334 // WP Cloud's warmer also tags its requests with a query marker.
2335 if ( ! $is_infra && isset( $_GET['x-cache-engine'] ) ) {
2336 $is_infra = true;
2337 }
2338
2339 /**
2340 * Allow a site or host to declare additional preload agents.
2341 *
2342 * @param bool $is_infra Whether this request bypasses the OTTO render throttle.
2343 * @param string $ua The request user-agent.
2344 */
2345 if ( function_exists( 'apply_filters' ) ) {
2346 $is_infra = (bool) apply_filters( 'metasync_otto_unthrottled_agent', $is_infra, $ua );
2347 }
2348
2349 return $is_infra;
2350 }
2351