PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.18
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.18
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.18, at otto/otto_pixel.php

2,265 lines 98.6 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'] && $detection['bot_type'] !== 'search_engine' ) {
526 $normalized_url = strtok( $current_url, '?' );
527 if ( $normalized_url === false ) {
528 $normalized_url = $current_url;
529 }
530 // Lowercase + length cap so attacker-controlled bot_name variations
531 // (EvilBota, EvilBotB, EvilBotc, ...) collapse to a bounded key space
532 // and cannot flood wp_options with unique transient rows.
533 $bot_name = substr( strtolower( $detection['bot_name'] ?? 'unknown' ), 0, 32 );
534 $render_throttle_key = 'metasync_otto_rendered_' . md5( $normalized_url . '|' . $bot_name );
535 if ( get_transient( $render_throttle_key ) ) {
536 // Prevent page caches (WP Rocket, W3TC, etc.) from saving the
537 // un-OTTO'd response and serving it to subsequent human visitors.
538 if ( ! defined( 'DONOTCACHEPAGE' ) ) {
539 define( 'DONOTCACHEPAGE', true );
540 }
541 return;
542 }
543 set_transient( $render_throttle_key, 1, 5 * MINUTE_IN_SECONDS );
544 }
545
546 # Optionally skip OTTO processing for bot traffic (when the setting is enabled)
547 if ($bot_detector->should_skip_otto()) {
548 // Log the bot locally and count the saved API call
549 $bot_detector->log_detection($detection);
550 $bot_stats_db = Metasync_Otto_Bot_Statistics_Database::get_instance();
551 $bot_stats_db->increment_api_calls_saved();
552
553 // Skip OTTO processing for this bot
554 return;
555 }
556
557 # OPTIMIZED: Check if Otto should be disabled for WP Rocket compatibility
558 if (class_exists('WP_Rocket')) {
559 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
560
561 if ($wp_rocket_compat_mode === 'disable_otto') {
562 return; # Exit early, Otto is disabled when WP Rocket is active
563 }
564 }
565
566 # Skip OTTO for Divi AJAX pagination and paginated archive requests.
567 # ?et_blog = Divi AJAX pagination callback
568 # /page/N/ = paginated blog/archive pages — OTTO's buffer/HTTP render causes
569 # module numbering mismatch between page 1 (with TB template) and page N
570 # (without TB template), breaking Divi's JS pagination selector matching.
571 if (isset($_GET['et_blog']) || (is_paged() && !is_singular())) {
572 return;
573 }
574
575 # Skip OTTO on search-results pages. A search request carries its
576 # meaning entirely in the query string (e.g. /?s=term, or FiboSearch's
577 # /?s=term&post_type=product&dgwt_wcas=1). get_route() intentionally strips
578 # the query string to build a canonical, cache-key-stable route — which
579 # collapses a query-only search URL to the site root ('/'). OTTO then finds
580 # the HOME page's suggestions for that route and, on the HTTP render path,
581 # serves the home page in place of the search results (the address bar keeps
582 # the search URL — it is a silent content swap, not a redirect). This only
583 # surfaces for logged-in / uncached visitors, since page caches serve the
584 # correct pre-rendered search page to everyone else. Search-results pages
585 # have no per-canonical-URL OTTO suggestions of their own, so OTTO must never
586 # run here. The raw $_GET['s'] check is a defensive fallback for setups where
587 # the main query is altered before this point.
588 if (is_search() || !empty($_GET['s'])) {
589 return;
590 }
591
592 # Handle cache plugin compatibility early - before any caching happens
593 metasync_otto_handle_cache_compatibility();
594
595 # check if OTTO is disabled for this specific page/post
596 $post_id = get_the_ID();
597 if ($post_id && class_exists('Metasync_Otto_Frontend_Toolbar')) {
598 if (Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id)) {
599 return;
600 }
601 }
602
603 # Skip OTTO on MetaSync custom HTML / LPS-imported pages — they ship their
604 # own complete, self-contained SEO and OTTO must not inject or overwrite it
605 # with a different/older project's SEO. Resolve the queried object
606 # id (with get_the_ID() fallback) so the static-front-page case — an LPS home
607 # set as the WP front page, where is_page() is false — is still detected.
608 # Applying the skip here, upstream of the single render_route_html() entry,
609 # covers all three render paths (Rocket buffer, output buffer, HTTP fallback).
610 # Only singular views (posts/pages, incl. a static front page) can be a
611 # custom/LPS page; gate on is_singular() so an archive/search/term query
612 # can never have its object id mistaken for a custom page's post id.
613 $custom_page_id = is_singular() ? ( get_queried_object_id() ?: get_the_ID() ) : 0;
614 if (metasync_is_custom_or_lps_page($custom_page_id)) {
615 if (!headers_sent()) {
616 header('X-MetaSync-OTTO-Method: EXCLUDED');
617 }
618 return;
619 }
620
621 # check if we are having an otto request
622 if(!empty($_GET['is_otto_page_fetch'])){
623
624 # Block SEO plugins NOW for this internal fetch request
625 # metasync_otto_block_seo_plugins();
626 # $_SERVER['REQUEST_URI'] = remove_query_arg('is_otto_page_fetch', $_SERVER['REQUEST_URI']);
627 $block_title = !empty($_GET['otto_block_title']) && $_GET['otto_block_title'] === '1';
628 $block_description = !empty($_GET['otto_block_desc']) && $_GET['otto_block_desc'] === '1';
629
630 # Block SEO plugins conditionally based on what Otto has
631 if ($block_title || $block_description) {
632 metasync_otto_block_seo_plugins($block_title, $block_description);
633 }
634
635 # Remove ALL Otto parameters from REQUEST_URI to prevent them from appearing in pagination, etc.
636 $request_uri_raw = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
637 $_SERVER['REQUEST_URI'] = remove_query_arg(
638 ['is_otto_page_fetch', 'otto_block_title', 'otto_block_desc'],
639 $request_uri_raw
640 );
641
642 # Also remove from $_GET to prevent WordPress from using them
643 unset($_GET['is_otto_page_fetch']);
644 unset($_GET['otto_block_title']);
645 unset($_GET['otto_block_desc']);
646 return;
647 }
648
649 # to avoid unnecessary processese
650 # OPTIMIZED: check that otto is configured
651 # And the UUID is properly set before running OTT
652
653 # check that we have the option
654 if(!Metasync_Otto_Config::is_otto_enabled()){
655 return;
656 }
657
658 # check that otto is enabled
659 if(!$otto_enabled){
660 return;
661 }
662
663 # get the otto uuid
664 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
665
666 # start the class
667 $otto = new Metasync_otto_pixel($otto_uuid);
668
669 # call render
670 $otto->render_route_html();
671 }
672
673 /**
674 * Handle cache plugin compatibility with Otto
675 * Controls DONOTCACHEPAGE constant based on active plugins and configuration
676 * This function is called early in the WordPress lifecycle
677 */
678 function metasync_otto_handle_cache_compatibility() {
679 # Detect active plugins. SG Optimizer detection now lives in
680 # metasync_otto_disable_sg_page_cache(), which is invoked later
681 # once OTTO confirms suggestions for the URL.
682 $brizy_active = class_exists('Brizy_Editor') || defined('BRIZY_VERSION');
683 $wp_rocket_active = class_exists('WP_Rocket');
684
685 # OPTIMIZED: Get configuration option using cached config
686 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
687
688 # Check for Brizy posts in database
689 global $wpdb;
690 $has_brizy_posts = false;
691
692 if ($brizy_active) {
693 # OPTIMIZED: Check cache first (1-hour TTL) to avoid querying on every page load
694 $cached = get_transient('metasync_has_brizy_posts');
695 if ($cached !== false) {
696 $has_brizy_posts = ($cached === 'yes');
697 } else {
698 # Query database only if cache missed
699 $has_brizy_posts = $wpdb->get_var(
700 "SELECT COUNT(*) FROM {$wpdb->postmeta}
701 WHERE meta_key = 'brizy_post_uid'
702 AND meta_value != ''
703 LIMIT 1"
704 );
705
706 # Cache result for 1 hour
707 $result = !empty($has_brizy_posts) ? 'yes' : 'no';
708 set_transient('metasync_has_brizy_posts', $result, HOUR_IN_SECONDS);
709 }
710 }
711
712 # Determine if DONOTCACHEPAGE should be set
713 $should_set_donotcachepage = false;
714
715 # Case 1: Brizy is active with posts - always needed. Brizy pages are
716 # dynamically rendered regardless of OTTO, so caching must be disabled
717 # whenever Brizy posts exist.
718 if ($brizy_active && !empty($has_brizy_posts)) {
719 $should_set_donotcachepage = true;
720 }
721
722 # Case 2: User explicitly disabled Otto for WP Rocket compatibility
723 elseif ($wp_rocket_active && $wp_rocket_compat_mode === 'disable_otto') {
724 $should_set_donotcachepage = true;
725 return; # Exit early, Otto won't run
726 }
727
728 # Case 3: WP Rocket active with auto/buffer mode - DON'T set DONOTCACHEPAGE
729 # This allows WP Rocket optimizations to continue working
730
731 # NOTE: SiteGround SG Optimizer cache bypass is intentionally NOT
732 # handled here. Emitting no-cache headers / disabling SG cache on this hook
733 # fired on EVERY front-end page — including pages OTTO never modifies —
734 # forcing SG to bypass its page cache site-wide and spiking CPU. The SG
735 # bypass is now deferred to metasync_otto_disable_sg_page_cache(), called
736 # from render_route_html() only after OTTO confirms it has suggestions for
737 # the current URL.
738
739 # Only set DONOTCACHEPAGE if needed
740 if ($should_set_donotcachepage && !defined('DONOTCACHEPAGE')) {
741 define('DONOTCACHEPAGE', true);
742 }
743 }
744
745 /**
746 * Disable SiteGround SG Optimizer page caching for the CURRENT request only.
747 *
748 * Called from Metasync_otto_pixel::render_route_html() once OTTO has confirmed
749 * it has suggestions to apply to the requested URL. Scoping the no-cache
750 * override to pages OTTO actually modifies — rather than emitting it on every
751 * front-end page via the unconditional `wp` hook — keeps SG's page cache
752 * working site-wide and avoids the CPU spike reported in.
753 *
754 * Only meaningful on SiteGround sites without WP Rocket; a no-op otherwise.
755 */
756 function metasync_otto_disable_sg_page_cache() {
757 # Only relevant when SG Optimizer is active and WP Rocket is not present.
758 if (class_exists('WP_Rocket')) {
759 return;
760 }
761 if (!is_plugin_active('sg-cachepress/sg-cachepress.php')) {
762 return;
763 }
764
765 if (!defined('DONOTCACHEPAGE')) {
766 define('DONOTCACHEPAGE', true);
767 }
768 if (!defined('SG_CachePress_SUPERCACHER')) {
769 define('SG_CachePress_SUPERCACHER', false);
770 }
771
772 add_filter('sgo_html_cache_disable', '__return_true', 999);
773 add_filter('sgo_css_combine_exclude', '__return_true', 999);
774 add_filter('sgo_js_combine_exclude', '__return_true', 999);
775 add_filter('sgo_cache_this_page', '__return_false', 999);
776
777 if (!headers_sent()) {
778 header('Cache-Control: no-cache, must-revalidate, max-age=0');
779 header('X-Accel-Expires: 0');
780 }
781 }
782
783 /**
784 * Block SEO plugins conditionally based on what Otto is providing
785 * Only blocks title if Otto has title, only blocks description if Otto has description
786 * This prevents duplicate SEO tags while allowing fallback to SEO plugins when Otto has no data.
787 * Supports Yoast SEO, Rank Math, and AIOSEO (free + pro).
788 *
789 * @param bool $block_title Whether to block title tags.
790 * @param bool $block_description Whether to block description tags.
791 * @param array $description_tags Optional. Granular list of OTTO-provided description tags
792 * (e.g. ['meta[name=description]', 'meta[property=og:description]']).
793 * When provided, only matching AIOSEO tags are suppressed.
794 * When empty, all AIOSEO description tags are suppressed (legacy behavior).
795 */
796 function metasync_otto_block_seo_plugins($block_title = false, $block_description = false, $description_tags = []) {
797 # Disable Yoast SEO (free and premium)
798 if (is_plugin_active('wordpress-seo/wp-seo.php') ||
799 is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {
800
801 # TITLE: Never block Yoast's title output during SSR fetch.
802 # Yoast removes WordPress's native _wp_render_title_tag action and is the sole
803 # renderer of the <title> tag. Returning false/empty from wpseo_title or removing
804 # Title_Presenter leaves the page with NO <title> tag at all — OTTO's buffer
805 # post-processing then has nothing to replace, producing a missing title.
806 # Instead, let Yoast render its own title; OTTO's replace_title() will overwrite
807 # it in the final HTML buffer. deduplicate_title_tags() cleans up any duplicates.
808
809 # Block description only if Otto has description
810 if ($block_description) {
811 add_filter('wpseo_metadesc', '__return_false', 999);
812 add_filter('wpseo_meta_description', '__return_false', 999);
813 add_filter('wpseo_metakeywords', '__return_false', 999);
814 }
815
816 # Block Yoast's modern presenters — description only, never title
817 add_filter('wpseo_frontend_presenters', function($presenters) use ($block_description) {
818 if (!is_array($presenters)) return $presenters;
819
820 $presenters_to_remove = [];
821
822
823 # Remove description presenters only when OTTO has a description
824 if ($block_description) {
825 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Meta_Description_Presenter';
826 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Open_Graph\Description_Presenter';
827 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Twitter\Description_Presenter';
828 }
829
830 foreach ($presenters as $key => $presenter) {
831 // Safely get class name, suppressing autoload errors
832 // This prevents warnings when Composer autoloader tries to load deprecated Yoast files
833 $class_name = is_object($presenter) ? @get_class($presenter) : '';
834
835 if (!empty($class_name) && in_array($class_name, $presenters_to_remove)) {
836 unset($presenters[$key]);
837 }
838 }
839 return $presenters;
840 }, 999);
841 }
842
843 # Disable Rank Math
844 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
845 is_plugin_active('seo-by-rankmath/rank-math.php')) {
846
847 if ($block_title) {
848 add_filter('rank_math/frontend/title', '__return_empty_string', 999);
849 }
850
851 if ($block_description) {
852 add_filter('rank_math/frontend/description', '__return_false', 999);
853 add_filter('rank_math/frontend/show_keywords', '__return_false', 999);
854 }
855 }
856
857 # Disable AIOSEO (free and pro)
858 if (is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
859 is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
860
861 if ($block_title) {
862 add_filter('aioseo_title', '__return_empty_string', 999);
863 add_filter('aioseo_facebook_tags', function($meta) {
864 if (is_array($meta)) { unset($meta['og:title']); }
865 return $meta;
866 }, 999);
867 add_filter('aioseo_twitter_tags', function($meta) {
868 if (is_array($meta)) { unset($meta['twitter:title']); }
869 return $meta;
870 }, 999);
871 }
872
873 if ($block_description) {
874 # Use granular tag list when available to only block what OTTO provides
875 $tags = !empty($description_tags) ? $description_tags : [];
876 $block_standard = empty($tags) || in_array('meta[name=description]', $tags);
877 $block_og_desc = empty($tags) || in_array('meta[property=og:description]', $tags);
878 $block_tw_desc = empty($tags) || in_array('meta[name=twitter:description]', $tags);
879
880 if ($block_standard) {
881 add_filter('aioseo_description', '__return_empty_string', 999);
882 }
883 if ($block_og_desc) {
884 add_filter('aioseo_facebook_tags', function($meta) {
885 if (is_array($meta)) { unset($meta['og:description']); }
886 return $meta;
887 }, 999);
888 }
889 if ($block_tw_desc) {
890 add_filter('aioseo_twitter_tags', function($meta) {
891 if (is_array($meta)) { unset($meta['twitter:description']); }
892 return $meta;
893 }, 999);
894 }
895 }
896 }
897 }
898 # Check whether OTTO is ALSO injected via JavaScript on the site (a misconfiguration
899 # we warn admins about).
900 #
901 # The actual detection makes a loopback HTTP request to the site's OWN url. On hosts
902 # that disallow same-server loopback (e.g. SiteGround), that request blocks for the
903 # full timeout and then fails — and when it runs inline on admin_notices it makes
904 # every wp-admin page hang. It must therefore NEVER run inline on an admin request.
905 #
906 # metasync_check_otto_js() is now read-only: it returns the cached result and, on a
907 # cache miss, schedules a one-off BACKGROUND job (WP-Cron) to compute it. The notice
908 # simply shows nothing until the background result is available.
909 function metasync_check_otto_js(){
910
911 $cache_key = 'metasync_otto_js_detected';
912 $cached = get_transient($cache_key);
913
914 if ($cached !== false) {
915 return $cached === 'yes';
916 }
917
918 # No cached result yet — compute it off the request so we never block admin
919 # with a loopback call. Show nothing this load.
920 metasync_schedule_otto_js_check();
921 return false;
922 };
923
924 # Queue the background loopback detection if it isn't already scheduled.
925 function metasync_schedule_otto_js_check(){
926 if (!wp_next_scheduled('metasync_otto_js_check_event')) {
927 wp_schedule_single_event(time() + 5, 'metasync_otto_js_check_event');
928 }
929 }
930
931 # Background worker (runs in WP-Cron context, NOT on the admin request): performs the
932 # blocking loopback request and caches the result. Hooked to metasync_otto_js_check_event.
933 function metasync_run_otto_js_check(){
934 $cache_key = 'metasync_otto_js_detected';
935
936 # the site url with the internal-fetch marker so OTTO does not re-process it
937 $site_url = site_url() . '?is_otto_page_fetch=1';
938
939 $page_data = wp_remote_get($site_url, array('timeout' => 5, 'sslverify' => false));
940
941 if (is_wp_error($page_data)) {
942 # Loopback failed (host likely blocks same-server requests). Cache "no" so we
943 # don't keep re-queuing the check on every admin page load.
944 set_transient($cache_key, 'no', HOUR_IN_SECONDS);
945 return;
946 }
947
948 $body = wp_remote_retrieve_body($page_data);
949
950 # Detect the OTTO script via a lightweight regex instead of parsing
951 # the entire fetched page into a SimpleHtmlDom tree (which exhausted memory
952 # on large pages — this check runs on admin page loads). We only need to
953 # confirm a <script id="sa-dynamic-optimization" ... data-uuid="..."> exists.
954 $found = preg_match(
955 '/<script\b[^>]*\bid=["\']sa-dynamic-optimization["\'][^>]*\bdata-uuid=["\'][^"\']+["\']/i',
956 (string) $body
957 )
958 || preg_match(
959 # Attribute order may vary (data-uuid before id)
960 '/<script\b[^>]*\bdata-uuid=["\'][^"\']+["\'][^>]*\bid=["\']sa-dynamic-optimization["\']/i',
961 (string) $body
962 );
963
964 if ($found) {
965 set_transient($cache_key, 'yes', 12 * HOUR_IN_SECONDS);
966 return;
967 }
968
969 set_transient($cache_key, 'no', 12 * HOUR_IN_SECONDS);
970 }
971
972 # Register the background worker hook.
973 add_action('metasync_otto_js_check_event', 'metasync_run_otto_js_check');
974
975 # Handle AJAX Clear Cache request
976 # NOTE: Cache system removed - this is now a no-op
977 function metasync_clear_otto_cache_handler() {
978 if (!empty($_GET['clear_otto_cache'])) {
979 delete_transient('metasync_otto_js_detected');
980 # Re-run the JS detection in the background so the notice refreshes.
981 metasync_schedule_otto_js_check();
982 # Cache system has been removed - no cache to clear
983 wp_send_json_success(['message' => 'Cache system removed - all pages processed in real-time']);
984 }
985 else {
986 wp_send_json_error(['message' => 'Missing parameter']);
987 }
988 }
989
990 # Clear cache hook
991 add_action('wp_ajax_metasync_clear_otto_cache', 'metasync_clear_otto_cache_handler');
992
993 # add admin action to check script
994 function metasync_show_otto_ssr_notice() {
995 if (!Metasync::current_user_has_plugin_access()) {
996 return; // Only show to admins
997 }
998
999 # Get the plugin name using centralized method
1000 $plugin_name = Metasync::get_effective_plugin_name();
1001 $whitelabel_otto_name = Metasync::get_whitelabel_otto_name();
1002 if (metasync_check_otto_js()) {
1003
1004 # Show admin notice with plugin name included in the message
1005 echo '<div class="notice notice-error">
1006 <p><b>Warning from ' . esc_html($plugin_name) . '</b>
1007 <br>
1008 ' . 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
1009 </p>
1010 </div>';
1011 }
1012 }
1013
1014 add_action('admin_notices', 'metasync_show_otto_ssr_notice');
1015
1016
1017 # staging dummy change
1018 # load otto in the wp hook
1019 add_action('wp', 'metasync_start_otto');
1020
1021 # ENHANCED OTTO SEO INTEGRATION
1022 # Register async SEO processing hook
1023 add_action('metasync_process_seo_job', 'metasync_process_otto_seo_data', 10, 3);
1024 add_action('metasync_process_otto_crawl_url_job', 'metasync_handle_otto_crawl_url_job', 10, 2);
1025 add_action('metasync_process_otto_batch_cache_job', 'metasync_handle_otto_batch_cache_job');
1026
1027 # Process OTTO SEO data and update WordPress meta fields for SEO plugins
1028 # This function now runs asynchronously via WordPress cron system
1029 #
1030 # @param string $route Fully-qualified URL to process.
1031 # @param bool $allow_defer When false, skip CPU-deferral rescheduling (used when
1032 # called synchronously from crawl_url_job whose own retry
1033 # mechanism already handles failures).
1034 # @param int $deferral_count How many times this job has already been deferred for
1035 # CPU load. Prevents infinite reschedule loops.
1036
1037 function metasync_process_otto_seo_data($route, $allow_defer = true, $deferral_count = 0) {
1038 # Maximum number of times a job can be deferred before it is dropped.
1039 $max_deferrals = defined('METASYNC_SEO_JOB_MAX_DEFERRALS') ? METASYNC_SEO_JOB_MAX_DEFERRALS : 5;
1040 $lock_key = null;
1041 $lock_acquired = false;
1042
1043 try {
1044 # Validate input
1045 if (empty($route) || !is_string($route)) {
1046 return false;
1047 }
1048
1049 # CPU load check — defer if server is under load.
1050 # Only reschedule when called from the cron hook (allow_defer=true) and
1051 # we haven't exceeded the maximum deferral count.
1052 # Checked BEFORE acquiring the lock so that deferred jobs don't
1053 # acquire-then-immediately-release it (which defeats concurrency protection).
1054 if (!Metasync_CPU_Monitor::is_load_safe()) {
1055 if ($allow_defer) {
1056 if ($deferral_count < $max_deferrals) {
1057 wp_schedule_single_event(
1058 time() + 60,
1059 'metasync_process_seo_job',
1060 array($route, true, $deferral_count + 1)
1061 );
1062 return false;
1063 }
1064 # deferral budget exhausted — RUN the job late instead
1065 # of dropping it. The write is idempotent and bounded, and a
1066 # late sync is strictly better than SEO data that never
1067 # arrives. No new cron events are created, so the
1068 # anti-pile-up guarantee is preserved. Fall through.
1069 } else {
1070 # allow_defer=false (sync path): return false without creating
1071 # cron events — OTTO's next crawl re-triggers this URL.
1072 return false;
1073 }
1074 }
1075
1076 # Concurrency lock: prevent multiple SEO jobs from running simultaneously.
1077 # Uses a per-URL transient lock with a 120s TTL as a safety net (the lock is
1078 # explicitly deleted on completion). If the lock exists another run is already
1079 # processing this URL — reschedule once with a short delay instead of stacking.
1080 $lock_key = 'metasync_seo_lock_' . md5($route);
1081 if (get_transient($lock_key) !== false) {
1082 if ($allow_defer && $deferral_count < $max_deferrals) {
1083 wp_schedule_single_event(
1084 time() + 30,
1085 'metasync_process_seo_job',
1086 array($route, true, $deferral_count + 1)
1087 );
1088 }
1089 return false;
1090 }
1091 set_transient($lock_key, true, 120);
1092 $lock_acquired = true;
1093
1094 # Resolve redirect table: use final destination URL before 404 checks and OTTO processing
1095 $route = metasync_otto_resolve_redirect_to_final_url($route);
1096
1097 # Skip excluded URLs - don't process SEO data for them
1098 if (metasync_is_otto_url_excluded($route)) {
1099 //error_log('MetaSync OTTO: Skipping SEO processing for excluded URL: ' . $route);
1100 return false;
1101 }
1102
1103 # Pre-flight 404 check: exclude URLs that would return 404 before making API call
1104 if (!metasync_otto_is_url_available($route)) {
1105 error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route}");
1106 metasync_otto_auto_exclude_404_url($route);
1107 return false;
1108 }
1109
1110 # Get OTTO UUID from settings
1111 # OPTIMIZED: Use cached options
1112 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
1113
1114 if (empty($otto_uuid)) {
1115 return false;
1116 }
1117
1118 # Meta descriptions are always enabled by default - no check needed
1119
1120 # Fetch SEO data from OTTO API
1121 $seo_data = metasync_fetch_otto_seo_data($route, $otto_uuid);
1122
1123 if (!$seo_data) {
1124 metasync_record_failed_action( 'metasync_process_seo_job' );
1125 return false;
1126 }
1127
1128 # Mark this URL as crawled by OTTO for SSR
1129 # Extract domain and path from route
1130 $parsed_url = parse_url($route);
1131 $domain_with_scheme = ($parsed_url['scheme'] ?? 'https') . '://' . ($parsed_url['host'] ?? '');
1132 $url_path = ($parsed_url['path'] ?? '/');
1133
1134 # Create crawl data structure
1135 $crawl_data = array(
1136 'domain' => $domain_with_scheme,
1137 'urls' => array($url_path)
1138 );
1139
1140 # Load Otto pixel class and save crawl data
1141 $otto_pixel = new Metasync_otto_pixel($otto_uuid);
1142 $otto_pixel->save_crawl_data($crawl_data);
1143
1144 # Get WordPress post ID from URL
1145 $post_id = url_to_postid($route);
1146
1147 # Special handling for WooCommerce shop page (url_to_postid doesn't work for it)
1148 if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) {
1149 # Check if this URL is the WooCommerce shop page
1150 $shop_page_id = wc_get_page_id('shop');
1151 if ($shop_page_id > 0) {
1152 $shop_url = get_permalink($shop_page_id);
1153 $route_normalized = rtrim($route, '/');
1154 $shop_url_normalized = rtrim($shop_url, '/');
1155
1156 if ($route_normalized === $shop_url_normalized) {
1157 $post_id = $shop_page_id;
1158 }
1159 }
1160 }
1161
1162 # Try to find WooCommerce product by URL if url_to_postid failed
1163 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
1164 # Extract product slug from URL
1165 $product_slug = basename(parse_url($route, PHP_URL_PATH));
1166
1167 # Try to get product by slug
1168 $products = wc_get_products(array(
1169 'name' => $product_slug,
1170 'limit' => 1,
1171 'status' => 'publish',
1172 ));
1173
1174 if (empty($products)) {
1175 # Fallback: try by slug using WP_Query
1176 $args = array(
1177 'post_type' => 'product',
1178 'name' => $product_slug,
1179 'posts_per_page' => 1,
1180 'post_status' => 'publish',
1181 );
1182 $query = new WP_Query($args);
1183
1184 if ($query->have_posts()) {
1185 $product_post = $query->posts[0];
1186 $post_id = $product_post->ID;
1187 }
1188 } else {
1189 $product = $products[0];
1190 $post_id = $product->get_id();
1191 }
1192 }
1193
1194 if (!$post_id || $post_id <= 0) {
1195 # Check if this is a category page
1196 if (strpos($route, '/category/') !== false) {
1197 # Extract category slug from URL
1198 $category_slug = basename(parse_url($route, PHP_URL_PATH));
1199 $category = get_category_by_slug($category_slug);
1200
1201 if ($category) {
1202 # Check if category would return 404 before applying OTTO changes
1203 if (metasync_would_term_return_404($category->term_id, 'category', $route)) {
1204 error_log("MetaSync OTTO: Skipping SEO processing for category that would return 404: {$route} (Category ID: {$category->term_id})");
1205 metasync_otto_auto_exclude_404_url($route);
1206 return false;
1207 }
1208
1209 # Update comprehensive category SEO meta fields
1210 $update_result = metasync_update_comprehensive_category_seo_fields($category->term_id, $seo_data);
1211
1212 if ($update_result['updated']) {
1213 # Prepare trimmed values to 30 characters
1214 $trim = function($value) {
1215 if ($value === null) { return ''; }
1216 $value = (string) $value;
1217 $value = trim($value);
1218 if (mb_strlen($value) > 30) {
1219 return mb_substr($value, 0, 30);
1220 }
1221 return $value;
1222 };
1223
1224 # Log individual field updates for category
1225 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1226 $short = '';
1227 $title = '';
1228
1229 switch ($field_type) {
1230 case 'meta_title':
1231 $short = $trim($field_value);
1232 $title = "Category Meta Title Update ({$short}...)";
1233 break;
1234 case 'meta_description':
1235 $short = $trim($field_value);
1236 $title = "Category Meta Description Update ({$short}...)";
1237 break;
1238 case 'meta_keywords':
1239 $short = $trim($field_value);
1240 $title = "Category Meta Keywords Update ({$short}...)";
1241 break;
1242 case 'og_title':
1243 $short = $trim($field_value);
1244 $title = "Category Open Graph Title Update ({$short}...)";
1245 break;
1246 case 'og_description':
1247 $short = $trim($field_value);
1248 $title = "Category Open Graph Description Update ({$short}...)";
1249 break;
1250 case 'twitter_title':
1251 $short = $trim($field_value);
1252 $title = "Category Twitter Title Update ({$short}...)";
1253 break;
1254 case 'twitter_description':
1255 $short = $trim($field_value);
1256 $title = "Category Twitter Description Update ({$short}...)";
1257 break;
1258 case 'image_alt_data':
1259 $image_count = count($field_value);
1260 $title = "Category Image Alt Text Update ({$image_count} images)";
1261 break;
1262 case 'headings_data':
1263 $heading_count = count($field_value);
1264 $title = "Category Headings Update ({$heading_count} headings)";
1265 break;
1266 case 'structured_data':
1267 $title = 'Category Structured Data Update';
1268 break;
1269 }
1270
1271 if (!empty($title)) {
1272 metasync_log_sync_history([
1273 'title' => $title,
1274 'source' => 'OTTO SEO',
1275 'status' => 'published',
1276 'content_type' => 'Category SEO',
1277 'url' => $route,
1278 'meta_data' => json_encode([
1279 'field' => $field_type,
1280 'field_value' => $field_value,
1281 'category_id' => $category->term_id,
1282 'category_name' => $category->name
1283 ])
1284 ]);
1285 }
1286 }
1287
1288 return true;
1289 }
1290
1291 return false;
1292 }
1293 }
1294
1295 # Check if this is a WooCommerce product category
1296 if (strpos($route, '/product-category/') !== false) {
1297 # Extract product category slug from URL
1298 $category_slug = basename(parse_url($route, PHP_URL_PATH));
1299 $term = get_term_by('slug', $category_slug, 'product_cat');
1300
1301 if ($term && !is_wp_error($term)) {
1302 # Check if product category would return 404 before applying OTTO changes
1303 if (metasync_would_term_return_404($term->term_id, 'product_cat', $route)) {
1304 error_log("MetaSync OTTO: Skipping SEO processing for product category that would return 404: {$route} (Term ID: {$term->term_id})");
1305 metasync_otto_auto_exclude_404_url($route);
1306 return false;
1307 }
1308
1309 # Update comprehensive taxonomy SEO meta fields
1310 $update_result = metasync_update_comprehensive_taxonomy_seo_fields($term->term_id, 'product_cat', $seo_data);
1311
1312 if ($update_result['updated']) {
1313 # Prepare trimmed values to 30 characters
1314 $trim = function($value) {
1315 if ($value === null) { return ''; }
1316 $value = (string) $value;
1317 $value = trim($value);
1318 if (mb_strlen($value) > 30) {
1319 return mb_substr($value, 0, 30);
1320 }
1321 return $value;
1322 };
1323
1324 # Log individual field updates for product category
1325 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1326 $short = '';
1327 $title = '';
1328
1329 switch ($field_type) {
1330 case 'meta_title':
1331 $short = $trim($field_value);
1332 $title = "Product Category Meta Title Update ({$short}...)";
1333 break;
1334 case 'meta_description':
1335 $short = $trim($field_value);
1336 $title = "Product Category Meta Description Update ({$short}...)";
1337 break;
1338 case 'meta_keywords':
1339 $short = $trim($field_value);
1340 $title = "Product Category Meta Keywords Update ({$short}...)";
1341 break;
1342 case 'og_title':
1343 $short = $trim($field_value);
1344 $title = "Product Category Open Graph Title Update ({$short}...)";
1345 break;
1346 case 'og_description':
1347 $short = $trim($field_value);
1348 $title = "Product Category Open Graph Description Update ({$short}...)";
1349 break;
1350 case 'twitter_title':
1351 $short = $trim($field_value);
1352 $title = "Product Category Twitter Title Update ({$short}...)";
1353 break;
1354 case 'twitter_description':
1355 $short = $trim($field_value);
1356 $title = "Product Category Twitter Description Update ({$short}...)";
1357 break;
1358 case 'image_alt_data':
1359 $image_count = count($field_value);
1360 $title = "Product Category Image Alt Text Update ({$image_count} images)";
1361 break;
1362 case 'headings_data':
1363 $heading_count = count($field_value);
1364 $title = "Product Category Headings Update ({$heading_count} headings)";
1365 break;
1366 case 'structured_data':
1367 $title = 'Product Category Structured Data Update';
1368 break;
1369 }
1370
1371 if (!empty($title)) {
1372 metasync_log_sync_history([
1373 'title' => $title,
1374 'source' => 'OTTO SEO',
1375 'status' => 'published',
1376 'content_type' => 'WooCommerce Product Category SEO',
1377 'url' => $route,
1378 'meta_data' => json_encode([
1379 'field' => $field_type,
1380 'field_value' => $field_value,
1381 'term_id' => $term->term_id,
1382 'term_name' => $term->name,
1383 'taxonomy' => 'product_cat'
1384 ])
1385 ]);
1386 }
1387 }
1388
1389 return true;
1390 }
1391
1392 return false;
1393 }
1394 }
1395
1396 # Check if this is the home page (landing page)
1397 $site_url = rtrim(site_url(), '/');
1398 $route_clean = rtrim($route, '/');
1399
1400 if ($route_clean === $site_url) {
1401 # Get the home page (front page)
1402 $front_page_id = get_option('page_on_front');
1403 $home_page = null;
1404
1405 if ($front_page_id && $front_page_id > 0) {
1406 $home_page = get_post($front_page_id);
1407 } else {
1408 # If no static front page is set, get the latest post
1409 $home_page = get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null;
1410 }
1411
1412 if ($home_page) {
1413 # Check if home page would return 404 before applying OTTO changes
1414 if (metasync_would_page_return_404($home_page->ID, $route)) {
1415 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})");
1416 metasync_otto_auto_exclude_404_url($route);
1417 return false;
1418 }
1419
1420 # Update comprehensive home page SEO meta fields
1421 $update_result = metasync_update_comprehensive_seo_fields($home_page->ID, $seo_data);
1422
1423 if ($update_result['updated']) {
1424 # Clear relevant caches
1425 metasync_clear_post_seo_caches($home_page->ID);
1426
1427 # Prepare trimmed values to 30 characters
1428 $trim = function($value) {
1429 if ($value === null) { return ''; }
1430 $value = (string) $value;
1431 $value = trim($value);
1432 if (mb_strlen($value) > 30) {
1433 return mb_substr($value, 0, 30);
1434 }
1435 return $value;
1436 };
1437
1438 # Log individual field updates for home page
1439 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1440 $short = '';
1441 $title = '';
1442
1443 switch ($field_type) {
1444 case 'meta_title':
1445 $short = $trim($field_value);
1446 $title = "Home Page Meta Title Update ({$short}...)";
1447 break;
1448 case 'meta_description':
1449 $short = $trim($field_value);
1450 $title = "Home Page Meta Description Update ({$short}...)";
1451 break;
1452 case 'meta_keywords':
1453 $short = $trim($field_value);
1454 $title = "Home Page Meta Keywords Update ({$short}...)";
1455 break;
1456 case 'og_title':
1457 $short = $trim($field_value);
1458 $title = "Home Page Open Graph Title Update ({$short}...)";
1459 break;
1460 case 'og_description':
1461 $short = $trim($field_value);
1462 $title = "Home Page Open Graph Description Update ({$short}...)";
1463 break;
1464 case 'twitter_title':
1465 $short = $trim($field_value);
1466 $title = "Home Page Twitter Title Update ({$short}...)";
1467 break;
1468 case 'twitter_description':
1469 $short = $trim($field_value);
1470 $title = "Home Page Twitter Description Update ({$short}...)";
1471 break;
1472 case 'image_alt_data':
1473 $image_count = count($field_value);
1474 $title = "Home Page Image Alt Text Update ({$image_count} images)";
1475 break;
1476 case 'headings_data':
1477 $heading_count = count($field_value);
1478 $title = "Home Page Headings Update ({$heading_count} headings)";
1479 break;
1480 case 'structured_data':
1481 $title = 'Home Page Structured Data Update';
1482 break;
1483 }
1484
1485 if (!empty($title)) {
1486 metasync_log_sync_history([
1487 'title' => $title,
1488 'source' => 'OTTO SEO',
1489 'status' => 'published',
1490 'content_type' => 'Home Page SEO',
1491 'url' => $route,
1492 'meta_data' => json_encode([
1493 'field' => $field_type,
1494 'field_value' => $field_value,
1495 'post_id' => $home_page->ID
1496 ])
1497 ]);
1498 }
1499 }
1500
1501 return true;
1502 }
1503
1504 return false;
1505 }
1506 }
1507
1508 # Check if this is the blog/posts page (page_for_posts)
1509 $posts_page_id = intval(get_option('page_for_posts'));
1510 if ($posts_page_id > 0) {
1511 $posts_page = get_post($posts_page_id);
1512 $posts_page_url = rtrim(get_permalink($posts_page_id), '/');
1513
1514 if ($posts_page && $route_clean === $posts_page_url) {
1515 # Check if blog page would return 404
1516 if (metasync_would_page_return_404($posts_page->ID, $route)) {
1517 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})");
1518 metasync_otto_auto_exclude_404_url($route);
1519 return false;
1520 }
1521
1522 # Update comprehensive blog page SEO meta fields
1523 $update_result = metasync_update_comprehensive_seo_fields($posts_page->ID, $seo_data);
1524
1525 if ($update_result['updated']) {
1526 # Clear relevant caches
1527 metasync_clear_post_seo_caches($posts_page->ID);
1528
1529 # Prepare trimmed values to 30 characters
1530 $trim = function($value) {
1531 if ($value === null) { return ''; }
1532 $value = (string) $value;
1533 $value = trim($value);
1534 if (mb_strlen($value) > 30) {
1535 return mb_substr($value, 0, 30);
1536 }
1537 return $value;
1538 };
1539
1540 # Log individual field updates for blog page
1541 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1542 $short = '';
1543 $title = '';
1544
1545 switch ($field_type) {
1546 case 'meta_title':
1547 $short = $trim($field_value);
1548 $title = "Blog Page Meta Title Update ({$short}...)";
1549 break;
1550 case 'meta_description':
1551 $short = $trim($field_value);
1552 $title = "Blog Page Meta Description Update ({$short}...)";
1553 break;
1554 case 'meta_keywords':
1555 $short = $trim($field_value);
1556 $title = "Blog Page Meta Keywords Update ({$short}...)";
1557 break;
1558 case 'og_title':
1559 $short = $trim($field_value);
1560 $title = "Blog Page Open Graph Title Update ({$short}...)";
1561 break;
1562 case 'og_description':
1563 $short = $trim($field_value);
1564 $title = "Blog Page Open Graph Description Update ({$short}...)";
1565 break;
1566 case 'twitter_title':
1567 $short = $trim($field_value);
1568 $title = "Blog Page Twitter Title Update ({$short}...)";
1569 break;
1570 case 'twitter_description':
1571 $short = $trim($field_value);
1572 $title = "Blog Page Twitter Description Update ({$short}...)";
1573 break;
1574 case 'image_alt_data':
1575 $image_count = count($field_value);
1576 $title = "Blog Page Image Alt Text Update ({$image_count} images)";
1577 break;
1578 case 'headings_data':
1579 $heading_count = count($field_value);
1580 $title = "Blog Page Headings Update ({$heading_count} headings)";
1581 break;
1582 case 'structured_data':
1583 $title = 'Blog Page Structured Data Update';
1584 break;
1585 }
1586
1587 if (!empty($title)) {
1588 metasync_log_sync_history([
1589 'title' => $title,
1590 'source' => 'OTTO SEO',
1591 'status' => 'published',
1592 'content_type' => 'Blog Page SEO',
1593 'url' => $route,
1594 'meta_data' => json_encode([
1595 'field' => $field_type,
1596 'field_value' => $field_value,
1597 'post_id' => $posts_page->ID
1598 ])
1599 ]);
1600 }
1601 }
1602
1603 return true;
1604 }
1605
1606 return false;
1607 }
1608 }
1609
1610 # URL didn't resolve to any supported entity (post, category, home page, blog page)
1611 # Treat as 404 and auto-exclude (e.g. deleted post, non-existent page)
1612 if (!metasync_otto_is_url_available($route)) {
1613 error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404 (no matching entity): {$route}");
1614 metasync_otto_auto_exclude_404_url($route);
1615 }
1616 return false;
1617 }
1618
1619 # Verify this is actually a post, page, or WooCommerce product
1620 $post = get_post($post_id);
1621
1622 # Get supported post types dynamically
1623 $supported_post_types = metasync_get_supported_post_types();
1624
1625 if (!$post || !in_array($post->post_type, $supported_post_types)) {
1626 # Skip unsupported post types
1627 return false;
1628 }
1629
1630 # Check if page would return 404 before applying OTTO changes
1631 if (metasync_would_page_return_404($post_id, $route)) {
1632 error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route} (Post ID: {$post_id}, Status: {$post->post_status})");
1633 metasync_otto_auto_exclude_404_url($route);
1634 return false;
1635 }
1636
1637 # Update comprehensive SEO meta fields
1638 $update_result = metasync_update_comprehensive_seo_fields($post_id, $seo_data);
1639
1640 if ($update_result['updated']) {
1641 # Clear relevant caches
1642 metasync_clear_post_seo_caches($post_id);
1643
1644 # Prepare trimmed values to 30 characters
1645 $trim = function($value) {
1646 if ($value === null) { return ''; }
1647 $value = (string) $value;
1648 $value = trim($value);
1649 if (mb_strlen($value) > 30) {
1650 return mb_substr($value, 0, 30);
1651 }
1652 return $value;
1653 };
1654
1655 # Log individual field updates
1656 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1657 $short = '';
1658 $title = '';
1659
1660 switch ($field_type) {
1661 case 'meta_title':
1662 $short = $trim($field_value);
1663 $title = 'Meta Title Update (' . $short . '...)';
1664 break;
1665 case 'meta_description':
1666 $short = $trim($field_value);
1667 $title = 'Meta Description Update (' . $short . '...)';
1668 break;
1669 case 'meta_keywords':
1670 $short = $trim($field_value);
1671 $title = 'Meta Keywords Update (' . $short . '...)';
1672 break;
1673 case 'og_title':
1674 $short = $trim($field_value);
1675 $title = 'Open Graph Title Update (' . $short . '...)';
1676 break;
1677 case 'og_description':
1678 $short = $trim($field_value);
1679 $title = 'Open Graph Description Update (' . $short . '...)';
1680 break;
1681 case 'twitter_title':
1682 $short = $trim($field_value);
1683 $title = 'Twitter Title Update (' . $short . '...)';
1684 break;
1685 case 'twitter_description':
1686 $short = $trim($field_value);
1687 $title = 'Twitter Description Update (' . $short . '...)';
1688 break;
1689 case 'image_alt_data':
1690 $image_count = count($field_value);
1691 $title = "Image Alt Text Update ({$image_count} images)";
1692 break;
1693 case 'headings_data':
1694 $heading_count = count($field_value);
1695 $title = "Headings Update ({$heading_count} headings)";
1696 break;
1697 case 'structured_data':
1698 $title = 'Structured Data Update';
1699 break;
1700 }
1701
1702 if (!empty($title)) {
1703 metasync_log_sync_history([
1704 'title' => $title,
1705 'source' => 'OTTO SEO',
1706 'status' => 'published',
1707 'content_type' => 'SEO Meta',
1708 'url' => $route,
1709 'meta_data' => json_encode([
1710 'field' => $field_type,
1711 'field_value' => $field_value,
1712 'post_id' => $post_id
1713 ])
1714 ]);
1715 }
1716 }
1717
1718 return true;
1719 }
1720
1721 return false;
1722
1723 } catch (Exception $e) {
1724 metasync_record_failed_action( 'metasync_process_seo_job' );
1725 return false;
1726 } finally {
1727 # Release the concurrency lock only if we actually acquired it.
1728 # Early returns (CPU deferral, lock contention) must NOT delete a lock
1729 # that another process may be holding.
1730 if ($lock_acquired && $lock_key) {
1731 delete_transient($lock_key);
1732 }
1733 }
1734 }
1735
1736 /**
1737 * Log sync history entry
1738 * @param array $data Sync data to log
1739 */
1740 function metasync_log_sync_history($data) {
1741 try {
1742 // Classes are now autoloaded, no need for manual require
1743 $sync_db = new Metasync_Sync_History_Database();
1744
1745 // Minimal duplicate prevention within short time window
1746 if (!empty($data['title']) && !empty($data['source'])) {
1747 global $wpdb;
1748 $table = $wpdb->prefix . Metasync_Sync_History_Database::$table_name;
1749 $recent = $wpdb->get_var($wpdb->prepare(
1750 "SELECT COUNT(*) FROM `$table` WHERE title = %s AND source = %s AND created_at >= %s",
1751 $data['title'],
1752 $data['source'],
1753 gmdate('Y-m-d H:i:s', time() - 60)
1754 ));
1755 if ((int)$recent > 0) {
1756 return; // skip duplicate log within 60 seconds
1757 }
1758 }
1759
1760 $sync_db->add($data);
1761
1762 } catch (Exception $e) {
1763 error_log("MetaSync: Failed to log sync history: " . $e->getMessage());
1764 }
1765 }
1766
1767 /**
1768 * Get supported post types for OTTO SEO optimization
1769 * Includes WooCommerce products if WooCommerce is active
1770 *
1771 * @return array List of supported post types
1772 */
1773 function metasync_get_supported_post_types() {
1774 # Start with default post types
1775 $post_types = ['post', 'page'];
1776
1777 # Add WooCommerce product post type if WooCommerce is active
1778 if (class_exists('WooCommerce') || function_exists('is_woocommerce')) {
1779 $post_types[] = 'product';
1780 }
1781
1782 # Include all public custom post types (e.g. 'location', 'service', 'team', etc.)
1783 # so OTTO can write post meta for them during metasync_process_otto_seo_data().
1784 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
1785 if (!empty($custom_post_types)) {
1786 $post_types = array_merge($post_types, array_values($custom_post_types));
1787 }
1788
1789 # Allow developers to filter supported post types
1790 $post_types = apply_filters('metasync_otto_supported_post_types', $post_types);
1791
1792 return $post_types;
1793 }
1794
1795 /**
1796 * Get supported taxonomies for OTTO SEO optimization
1797 * Includes WooCommerce product categories and tags if WooCommerce is active
1798 *
1799 * @return array List of supported taxonomies
1800 */
1801 function metasync_get_supported_taxonomies() {
1802 # Start with default taxonomies
1803 $taxonomies = ['category'];
1804
1805 # Add WooCommerce taxonomies if WooCommerce is active
1806 if (class_exists('WooCommerce') || function_exists('is_woocommerce')) {
1807 $taxonomies[] = 'product_cat'; # WooCommerce product categories
1808 $taxonomies[] = 'product_tag'; # WooCommerce product tags
1809 }
1810
1811 # Allow developers to filter supported taxonomies
1812 $taxonomies = apply_filters('metasync_otto_supported_taxonomies', $taxonomies);
1813
1814 return $taxonomies;
1815 }
1816
1817 /**
1818 * Resolve a URL through the Redirect Manager table to its final destination (follows redirect chains).
1819 * Used before 404 checks and OTTO processing so the final canonical URL is used, not intermediate redirects.
1820 *
1821 * @param string $url Full URL (e.g. https://example.com/old-page)
1822 * @return string Final destination URL, or original $url if no redirect matches
1823 */
1824 function metasync_otto_resolve_redirect_to_final_url($url)
1825 {
1826 if (empty($url) || !is_string($url)) {
1827 return $url;
1828 }
1829 try {
1830 $db_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection-database.php';
1831 $class_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection.php';
1832 if (!file_exists($db_path) || !file_exists($class_path)) {
1833 return $url;
1834 }
1835 require_once $db_path;
1836 require_once $class_path;
1837 $db = new Metasync_Redirection_Database();
1838 $redirect = new Metasync_Redirection($db);
1839 return $redirect->resolve_url_to_final_destination($url, 10);
1840 } catch (Exception $e) {
1841 error_log('MetaSync OTTO: Redirect resolution failed for ' . $url . ' - ' . $e->getMessage());
1842 return $url;
1843 }
1844 }
1845
1846 /**
1847 * Auto-exclude a URL from OTTO with description "Auto-excluded: 404"
1848 * Called when a URL is detected as returning 404 so it won't be sent to OTTO again
1849 *
1850 * @param string $url Full URL to exclude (e.g. https://example.com/404-page)
1851 * @return bool|string True on success, false on failure, 'duplicate'/'reactivated' if already exists
1852 */
1853 function metasync_otto_auto_exclude_404_url($url)
1854 {
1855 if (empty($url) || !is_string($url)) {
1856 return false;
1857 }
1858 $url = filter_var($url, FILTER_SANITIZE_URL);
1859 $url = esc_url_raw($url);
1860 if (empty($url) || mb_strlen($url) > 2048) {
1861 return false;
1862 }
1863 try {
1864 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1865 $db = new Metasync_Otto_Excluded_URLs_Database();
1866 return $db->add([
1867 'url_pattern' => $url,
1868 'pattern_type' => 'exact',
1869 'description' => 'Auto-excluded: 404',
1870 'status' => 'active',
1871 'auto_excluded' => 1,
1872 ]);
1873 } catch (Exception $e) {
1874 error_log('MetaSync OTTO: Failed to auto-exclude 404 URL: ' . $url . ' - ' . $e->getMessage());
1875 return false;
1876 }
1877 }
1878
1879 /**
1880 * Remove a URL from the OTTO auto-exclusion list.
1881 * Called when OTTO sends a webhook for a URL, confirming it is valid and crawlable.
1882 * Only removes records where auto_excluded = 1 (never removes manual exclusions).
1883 *
1884 * @param string $url Full URL to un-exclude (e.g. https://example.com/location/page)
1885 * @return bool True on success
1886 */
1887 function metasync_otto_remove_auto_exclusion($url)
1888 {
1889 if (empty($url) || !is_string($url)) {
1890 return false;
1891 }
1892 try {
1893 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1894 $db = new Metasync_Otto_Excluded_URLs_Database();
1895 global $wpdb;
1896 $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
1897 # Normalize the same way is_url_excluded() does
1898 $url_normalized = rtrim(trim($url), '/');
1899 $records = $wpdb->get_results(
1900 $wpdb->prepare(
1901 "SELECT id FROM `{$table}` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'",
1902 $url_normalized
1903 )
1904 );
1905 if (!empty($records)) {
1906 $ids = array_map(function ($r) { return (int) $r->id; }, $records);
1907 $db->delete($ids);
1908 }
1909 return true;
1910 } catch (Exception $e) {
1911 return false;
1912 }
1913 }
1914
1915 /**
1916 * Check if a URL is MANUALLY excluded from OTTO (auto_excluded = 0).
1917 * Used at render time (metasync_start_otto) — auto-exclusions must NOT block
1918 * rendering because they are often false positives (e.g. custom post types that
1919 * url_to_postid() can't resolve). Auto-exclusions are only used to gate the
1920 * SEO meta-writing webhook path.
1921 *
1922 * @param string $url URL to check
1923 * @return bool True if URL has a manual exclusion
1924 */
1925 function metasync_is_otto_url_manually_excluded($url)
1926 {
1927 if (empty($url) || !is_string($url)) {
1928 return false;
1929 }
1930 try {
1931 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1932 global $wpdb;
1933 $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
1934 $url_normalized = rtrim(trim($url), '/');
1935
1936 $records = get_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY);
1937
1938 if ($records === false) {
1939 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- no user input, table name from $wpdb->prefix
1940 $records = $wpdb->get_results(
1941 "SELECT url_pattern, pattern_type FROM `{$table}` WHERE status = 'active' AND (auto_excluded = 0 OR auto_excluded IS NULL) ORDER BY created_at DESC"
1942 );
1943
1944 // Graceful recovery: auto_excluded column missing on pre-v2.7.4 installs.
1945 // Run ALTER TABLE to add it and treat URL as not excluded so OTTO continues rendering.
1946 if ($wpdb->last_error && strpos($wpdb->last_error, 'auto_excluded') !== false) {
1947 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1948 $wpdb->query("ALTER TABLE `{$table}` ADD COLUMN `auto_excluded` TINYINT(1) NOT NULL DEFAULT 0");
1949 return false;
1950 }
1951
1952 set_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY, $records ?: [], METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL);
1953 }
1954
1955 if (empty($records)) {
1956 return false;
1957 }
1958
1959 foreach ($records as $excluded) {
1960 $pattern = rtrim(trim($excluded->url_pattern), '/');
1961 $pattern_type = $excluded->pattern_type;
1962
1963 switch ($pattern_type) {
1964 case 'exact':
1965 if ($url_normalized === $pattern) {
1966 return true;
1967 }
1968 break;
1969 case 'contain':
1970 if (strpos($url_normalized, $pattern) !== false) {
1971 return true;
1972 }
1973 break;
1974 case 'start':
1975 if (strpos($url_normalized, $pattern) === 0) {
1976 return true;
1977 }
1978 break;
1979 }
1980 }
1981 return false;
1982 } catch (Exception $e) {
1983 return false;
1984 }
1985 }
1986
1987 /**
1988 * Check if a URL is excluded from OTTO
1989 * @param string $url URL to check
1990 * @return bool True if URL is excluded, false otherwise
1991 */
1992 function metasync_is_otto_url_excluded($url)
1993 {
1994 try {
1995 // Load database class
1996 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1997 $db = new Metasync_Otto_Excluded_URLs_Database();
1998
1999 // Check if URL is excluded
2000 return $db->is_url_excluded($url);
2001
2002 } catch (Exception $e) {
2003 return false;
2004 }
2005 }
2006
2007 /**
2008 * Check if a URL is now available (would NOT return 404)
2009 * Uses same resolution logic as metasync_process_otto_seo_data
2010 * Used when rechecking auto-excluded 404 URLs after 7 days
2011 *
2012 * @param string $url Full URL to check (e.g. https://example.com/page)
2013 * @return bool True if URL is accessible, false if it would return 404
2014 */
2015 function metasync_otto_is_url_available($url)
2016 {
2017 if (empty($url) || !is_string($url)) {
2018 return false;
2019 }
2020
2021 $route = metasync_otto_resolve_redirect_to_final_url($url);
2022 $post_id = url_to_postid($route);
2023
2024 # WooCommerce shop page
2025 if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) {
2026 $shop_page_id = wc_get_page_id('shop');
2027 if ($shop_page_id > 0) {
2028 $shop_url = get_permalink($shop_page_id);
2029 if (rtrim($route, '/') === rtrim($shop_url, '/')) {
2030 $post_id = $shop_page_id;
2031 }
2032 }
2033 }
2034
2035 # WooCommerce product by slug
2036 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
2037 $product_slug = basename(parse_url($route, PHP_URL_PATH));
2038 $products = wc_get_products(array('name' => $product_slug, 'limit' => 1, 'status' => 'publish'));
2039 if (!empty($products)) {
2040 $post_id = $products[0]->get_id();
2041 } else {
2042 $query = new WP_Query(array(
2043 'post_type' => 'product',
2044 'name' => $product_slug,
2045 'posts_per_page' => 1,
2046 'post_status' => 'publish',
2047 ));
2048 if ($query->have_posts()) {
2049 $post_id = $query->posts[0]->ID;
2050 }
2051 }
2052 }
2053
2054 if ($post_id && $post_id > 0) {
2055 $post = get_post($post_id);
2056 # Accept any post type — custom post types (e.g. 'location', 'service') are valid URLs.
2057 # The old in_array check against metasync_get_supported_post_types() caused CPT URLs
2058 # to be wrongly auto-excluded as "404" pages.
2059 if ($post) {
2060 return !metasync_would_page_return_404($post_id, $route);
2061 }
2062 }
2063
2064 # Category
2065 if (strpos($route, '/category/') !== false) {
2066 $category_slug = basename(parse_url($route, PHP_URL_PATH));
2067 $category = get_category_by_slug($category_slug);
2068 if ($category) {
2069 return !metasync_would_term_return_404($category->term_id, 'category', $route);
2070 }
2071 }
2072
2073 # WooCommerce product category
2074 if (strpos($route, '/product-category/') !== false) {
2075 $category_slug = basename(parse_url($route, PHP_URL_PATH));
2076 $term = get_term_by('slug', $category_slug, 'product_cat');
2077 if ($term && !is_wp_error($term)) {
2078 return !metasync_would_term_return_404($term->term_id, 'product_cat', $route);
2079 }
2080 }
2081
2082 # Home page
2083 if (rtrim($route, '/') === rtrim(site_url(), '/')) {
2084 $front_page_id = get_option('page_on_front');
2085 $home_page = ($front_page_id && $front_page_id > 0)
2086 ? get_post($front_page_id)
2087 : (get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null);
2088 if ($home_page) {
2089 return !metasync_would_page_return_404($home_page->ID, $route);
2090 }
2091 }
2092
2093 # Could not verify availability from local data (custom archive, paginated page, etc.).
2094 # Assume the URL IS available — OTTO only crawls reachable URLs, so if we can't
2095 # prove it's a 404, we should not auto-exclude it.
2096 return true;
2097 }
2098
2099 /**
2100 * Recheck auto-excluded 404 URLs when recheck_after has passed; remove from exclusion if now available
2101 * Uses recheck_after timestamp (default 7 days from exclusion) to decide when to recheck
2102 * Mark as permanent after 30 days if still 404 (no further rechecks)
2103 * Called by daily cron job
2104 */
2105 function metasync_otto_recheck_404_exclusions()
2106 {
2107 try {
2108 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2109 $db = new Metasync_Otto_Excluded_URLs_Database();
2110 $records = $db->get_auto_excluded_404_urls_due_for_recheck();
2111
2112 if (empty($records)) {
2113 return;
2114 }
2115
2116 $removed = 0;
2117 $marked_permanent = 0;
2118 $thirty_days_ago = strtotime('-30 days');
2119 $next_recheck = date('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS);
2120
2121 foreach ($records as $record) {
2122 $url = trim($record->url_pattern);
2123 if (empty($url)) {
2124 continue;
2125 }
2126 if (metasync_otto_is_url_available($url)) {
2127 $db->delete([$record->id]);
2128 $removed++;
2129 } else {
2130 # Still 404: if excluded 30+ days ago, mark as permanent (no more rechecks)
2131 $created_ts = strtotime($record->created_at);
2132 if ($created_ts <= $thirty_days_ago) {
2133 $db->update(['is_permanent' => 1], $record->id);
2134 $marked_permanent++;
2135 } else {
2136 # Schedule next recheck in 7 days
2137 $db->update(['recheck_after' => $next_recheck], $record->id);
2138 }
2139 }
2140 }
2141
2142 if ($removed > 0) {
2143 error_log("MetaSync OTTO: Recheck 404 exclusions - removed {$removed} URL(s) that are now available");
2144 }
2145 if ($marked_permanent > 0) {
2146 error_log("MetaSync OTTO: Recheck 404 exclusions - marked {$marked_permanent} URL(s) as permanent (still 404 after 30 days)");
2147 }
2148 } catch (Exception $e) {
2149 error_log('MetaSync OTTO: Recheck 404 exclusions failed - ' . $e->getMessage());
2150 }
2151 }
2152
2153 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
2154
2155 /**
2156 * Check if a post/page would return 404 without making HTTP request
2157 * Uses WordPress database checks for fast validation
2158 *
2159 * @param int $post_id WordPress post ID
2160 * @param string $url The URL being checked (optional, for logging)
2161 * @return bool True if page would return 404, false if accessible
2162 */
2163 function metasync_would_page_return_404($post_id, $url = '') {
2164 if (!$post_id || $post_id <= 0) {
2165 return true; // No post ID = 404
2166 }
2167
2168 # Get the post object
2169 $post = get_post($post_id);
2170 if (!$post) {
2171 return true; // Post doesn't exist = 404
2172 }
2173
2174 # 1. Check post status - must be 'publish' to be publicly accessible
2175 if ($post->post_status !== 'publish') {
2176 return true; // Draft, pending, private, etc. = 404
2177 }
2178
2179 # 2. Check if post is password protected (requires password to view)
2180 if (!empty($post->post_password)) {
2181 # Password protected posts are not publicly accessible without password
2182 return true; // Password protected = effectively 404 for public
2183 }
2184
2185 # 3. Check if post is in trash
2186 if ($post->post_status === 'trash') {
2187 return true; // Trashed = 404
2188 }
2189
2190 # 4. Check if post type is publicly queryable
2191 $post_type_object = get_post_type_object($post->post_type);
2192 if ($post_type_object && !$post_type_object->publicly_queryable) {
2193 # Some post types might not be publicly accessible
2194 # But we allow if it's in our supported types
2195 $supported_post_types = metasync_get_supported_post_types();
2196 if (!in_array($post->post_type, $supported_post_types)) {
2197 return true; // Not publicly queryable = 404
2198 }
2199 }
2200
2201 # 5. WordPress 5.7+ has a built-in function for this
2202 if (function_exists('is_post_publicly_viewable')) {
2203 if (!is_post_publicly_viewable($post)) {
2204 return true; // Not publicly viewable = 404
2205 }
2206 }
2207
2208 # 6. Check if post is scheduled for future (not yet published)
2209 if ($post->post_date > current_time('mysql')) {
2210 return true; // Future post = 404 until publish date
2211 }
2212
2213 # All checks passed - page should be accessible
2214 return false;
2215 }
2216
2217 /**
2218 * Check if a taxonomy term (category, tag, etc.) would return 404
2219 * Uses WordPress database checks for fast validation
2220 *
2221 * @param int $term_id Term ID
2222 * @param string $taxonomy Taxonomy name (e.g., 'category', 'product_cat')
2223 * @param string $url The URL being checked (optional, for logging)
2224 * @return bool True if term would return 404, false if accessible
2225 */
2226 function metasync_would_term_return_404($term_id, $taxonomy, $url = '') {
2227 if (!$term_id || $term_id <= 0 || empty($taxonomy)) {
2228 return true; // Invalid term = 404
2229 }
2230
2231 # Get the term object
2232 $term = get_term($term_id, $taxonomy);
2233 if (is_wp_error($term) || !$term) {
2234 return true; // Term doesn't exist = 404
2235 }
2236
2237 # Check if taxonomy is publicly queryable
2238 $taxonomy_object = get_taxonomy($taxonomy);
2239 if (!$taxonomy_object || !$taxonomy_object->public) {
2240 # Check if it's in our supported taxonomies
2241 $supported_taxonomies = metasync_get_supported_taxonomies();
2242 if (!in_array($taxonomy, $supported_taxonomies)) {
2243 return true; // Not publicly queryable = 404
2244 }
2245 }
2246
2247 # Terms are generally always accessible if they exist and taxonomy is public
2248 # WordPress doesn't have a "draft" status for terms like posts do
2249 # But we can check if the term has a count (has posts assigned)
2250 # Empty terms might not be useful, but they're still accessible
2251
2252 # All checks passed - term should be accessible
2253 return false;
2254 }
2255
2256 /**
2257 * Invalidate Brizy posts cache when posts are saved
2258 * OPTIMIZATION: Clears transient cache to ensure accurate detection
2259 */
2260 add_action('save_post', function($post_id) {
2261 # Check if this post has Brizy metadata
2262 if (get_post_meta($post_id, 'brizy_post_uid', true)) {
2263 delete_transient('metasync_has_brizy_posts');
2264 }
2265 }, 10, 1);