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

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