' . "\n"; echo '' . "\n"; # adding the otto tag to pages $plugin_version = defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown'; # OPTIMIZED: use cached uuid $otto_tag = ''; # out the otto tag echo $otto_tag; }, 1); # Priority 1 to output early in head /** * Start end point to handle requests on page updates * The Otto Crawler will call this end point once a page is updated **/ # function to register the route function metasync_otto_crawl_notify($request){ # get request data params $data = $request->get_json_params(); # fields $fields = ['domain', 'urls']; # validate the json request foreach ($fields as $key => $value) { # if the value is empty stop tehre if(empty($data[$value])){ # Handle the POST request return new WP_REST_Response(array( 'success' => false, 'message' => 'Invalid Field : '. $value, ), 400); } } # load otto pixel $otto_pixel = new Metasync_otto_pixel(false); # save the otto data first (cheap local DB write — kept synchronous) $otto_pixel->save_crawl_data($data); # Defer the expensive per-URL work (OTTO API fetch, post meta sync, # per-URL cache purge) to background jobs. The webhook caller enforces a # strict response-time budget; doing this work inline previously blocked # the response for up to 30s × N URLs and timed the caller out. # Each URL gets its own scheduled event so failures are isolated. # # WP-299: Cap the number of per-URL cron jobs scheduled per webhook batch. # Large crawl batches (100+ URLs) previously created hundreds of cron events # that overwhelmed WP-Cron on shared/managed hosts like WP Engine. Excess # URLs beyond the cap are silently skipped — OTTO will re-crawl them on the # next webhook cycle. $max_jobs_per_batch = defined('METASYNC_MAX_JOBS_PER_BATCH') ? METASYNC_MAX_JOBS_PER_BATCH : 25; $now = time(); $routes_to_process = array(); $scheduled_count = 0; foreach($data['urls'] AS $key => $url){ # Enforce per-batch cap to prevent cron overload if ($scheduled_count >= $max_jobs_per_batch) { break; } # prepare the route $route = $data['domain'] . $url; # validate the route $route = rtrim($route, '/'); # Resolve redirect table: use final destination URL before excluded/404 checks and OTTO processing $route = metasync_otto_resolve_redirect_to_final_url($route); # OTTO has confirmed this URL is crawlable. Remove any auto-exclusion that was # previously set (e.g. because url_to_postid() returned 0 for a custom post type # and metasync_otto_is_url_available() incorrectly treated it as a 404). # Manual exclusions (auto_excluded = 0) are left untouched. metasync_otto_remove_auto_exclusion($route); # Skip manually excluded URLs - don't queue OTTO processing for them if (metasync_is_otto_url_excluded($route)) { continue; } # Queue the per-URL processing for background execution. The handler # (metasync_handle_otto_crawl_url_job) performs transient warming, # SEO meta sync, and per-URL host cache purge. # Offset each event by $key seconds to avoid wp_schedule_single_event() # silently dropping duplicates when timestamp + hook + args collide. wp_schedule_single_event($now + $key, 'metasync_process_otto_crawl_url_job', array($route)); $routes_to_process[] = $route; $scheduled_count++; } # Schedule a single batch job for cache warming and edge CDN purge. # These operations benefit from batching: warm_urls() uses concurrent # fire-and-forget requests, and edge providers (Cloudflare, Fastly) # support multi-URL purge in a single API call. if (!empty($routes_to_process)) { $batch_time = $now + count($data['urls']) + 5; // run after all per-URL jobs wp_schedule_single_event($batch_time, 'metasync_process_otto_batch_cache_job', array($routes_to_process)); } # Track OTTO optimization event in GA4 (local, non-blocking) try { Metasync_GA4::get_instance()->track_otto_optimization($data); } catch (Exception $e) { // Analytics tracking failed, continue } # Return 200 immediately so the webhook caller does not time out. return new WP_REST_Response(array( 'success' => true, 'message' => 'OTTO crawl notification received', ), 200); } /** * Background handler for a single OTTO crawl-notify URL. * * Runs the per-URL sequence that was previously inline in metasync_otto_crawl_notify(): * warm the OTTO transient cache, sync SEO post meta, and clear the per-URL host cache. * * Cache warming and edge CDN purge are handled separately by * metasync_handle_otto_batch_cache_job() in a single batched event, because * those operations benefit from multi-URL batching (fewer API calls). * * On failure, retries up to METASYNC_OTTO_JOB_MAX_RETRIES times with 60s backoff. * After all retries are exhausted the failure is recorded via * metasync_record_failed_action() for surfacing in Site Health. * * @param string $route Fully-qualified URL to process. * @param int $retry_count Current retry attempt (0 = first run). */ function metasync_handle_otto_crawl_url_job($route, $retry_count = 0) { try { # Step 1: Warm OTTO transient cache (fetch fresh suggestions from OTTO API into WP transient) $otto_uuid = Metasync_Otto_Config::get_otto_uuid(); if (!empty($otto_uuid)) { $transient_cache = new Metasync_Otto_Transient_Cache($otto_uuid); $transient_cache->warm_cache($route); } # Step 2: Write SEO post meta synchronously BEFORE the cache purge. # This ensures the DB is fully up-to-date when Kinsta (or any host) re-populates # the cache on the very next request — eliminating the race condition where the # 1-second scheduled job hadn't run yet and stale meta got cached. # allow_defer=false: do NOT reschedule a new metasync_process_seo_job cron # event from this synchronous path — the crawl_url_job retry mechanism already # handles failures, and rescheduling here caused an unbounded cron pile-up (WP-299). metasync_process_otto_seo_data($route, false); # Step 3: Clear the per-URL cache entry $otto_pixel = new Metasync_otto_pixel(false); $otto_pixel->refresh_cache($route); Metasync_Cache_Purge::purge_single_url($route); } catch (Exception $e) { $max_retries = defined('METASYNC_OTTO_JOB_MAX_RETRIES') ? METASYNC_OTTO_JOB_MAX_RETRIES : 3; if ($retry_count < $max_retries) { # Schedule a retry with exponential backoff: 60s, 120s, 240s $delay = 60 * pow(2, $retry_count); wp_schedule_single_event(time() + $delay, 'metasync_process_otto_crawl_url_job', array($route, $retry_count + 1)); error_log('MetaSync OTTO: retrying crawl-url job for ' . $route . ' (attempt ' . ($retry_count + 1) . '/' . $max_retries . ') in ' . $delay . 's: ' . $e->getMessage()); } else { metasync_record_failed_action('metasync_process_otto_crawl_url_job'); error_log('MetaSync OTTO: background crawl-url job permanently failed for ' . $route . ' after ' . $max_retries . ' retries: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); } } } /** * Batch handler for cache warming and edge CDN purge. * * Runs after all per-URL jobs have had time to complete. Batching these * operations avoids N separate API calls to edge providers (Cloudflare * supports up to 30 tags per request) and lets warm_urls() issue concurrent * fire-and-forget requests. * * @param array $routes List of fully-qualified URLs to warm and purge. */ function metasync_handle_otto_batch_cache_job($routes) { try { # Re-populate the cache so OTTO-modified output is what gets stored. # Per-URL host cache was already cleared by individual jobs. # warm_urls() hits each URL with a non-blocking request so our code — # with fresh transients and post meta — is first to populate the host cache. Metasync_Cache_Purge::warm_urls($routes); # Purge edge CDN caches (Cloudflare, Fastly, Akamai, Sucuri, Sevalla, etc.) # Tag-based providers purge only the affected posts; full-flush providers fire once per batch. Metasync_Edge_Cache_Purge::purge($routes); } catch (Exception $e) { metasync_record_failed_action('metasync_process_otto_batch_cache_job'); error_log('MetaSync OTTO: batch cache job failed for ' . count($routes) . ' URLs: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); } } # NOTE: Cache system removed - these functions are no longer needed # Kept for backward compatibility in case old cache directories need cleanup function metasync_deleteDir($dir) { if (!is_dir($dir)) { return false; } $files = array_diff(scandir($dir), array('.', '..')); foreach ($files as $file) { $filePath = $dir . DIRECTORY_SEPARATOR . $file; if (is_dir($filePath)) { metasync_deleteDir($filePath); } else { unlink($filePath); } } return rmdir($dir); } # Cleanup function for removing old cache directories (if they exist) function metasync_invalidate_all_caches($folder = ''){ # Cache system removed - this function only exists to clean up old cache directories if(!defined('WP_CONTENT_DIR')){ return false; } $wp_content_dir = WP_CONTENT_DIR; $cache_dir = $wp_content_dir . '/metasync_caches'; if(in_array($folder, ['posts', 'pages'])){ $cache_dir = $cache_dir . '/' . $folder; } if(is_dir($cache_dir)){ metasync_deleteDir($cache_dir); } } function metasync_start_otto(){ # PERFORMANCE FIX: Cache is now enabled for speed # Skip initial cache cleanup to preserve existing cache # Cache files are valuable for performance - only clear on OTTO updates # Periodic cache clearing can be configured in plugin settings if needed # exclude AJAX requests and WooCommerce transactional pages from OTTO SSR # SSR is now ENABLED for: single products, product categories, product tags # SSR is SKIPPED for: cart, checkout # Note: title/description filters (pre_get_document_title, wp_head meta desc) # run on ALL pages regardless — they are hooked unconditionally in seo-functions.php # ── WooCommerce-independent cart/checkout protection (WP-374) ────────── # The is_cart()/is_checkout() guards below only recognize WooCommerce. Carts # from other systems — e.g. the Point of Rental "Catalog" plugin on # venturarental.com — are invisible to them, so OTTO would process those # pages and let caching layers store them. A cached cart page corrupts the # live cart (added items vanish because a stale page is served). The checks # here do not depend on WooCommerce being active. # Never run OTTO on non-GET requests. SSR exists for crawlers, which only # issue GET; POST/PUT/etc. are form or cart submissions that must pass # through untouched. (OTTO's own internal fetches use GET.) if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') { return; } # Skip OTTO on cart/checkout-type paths regardless of the cart plugin, and # signal caching plugins (WP Rocket/Kinsta/etc.) to never cache them — a # cached cart/checkout page is precisely what corrupts cart state. # Matched against the FIRST path segment (relative to the WP home path) so # subdirectory installs (/shop/cart) still work, while unrelated content pages # like /blog/cart or /features/checkout do NOT false-match. # Drop the query string with explode() (no shared internal pointer like strtok), # then lower-case and strip surrounding slashes. $otto_req_path = explode('?', (string) wp_unslash($_SERVER['REQUEST_URI'] ?? ''), 2)[0]; $otto_req_path = strtolower(trim($otto_req_path, '/')); # Strip the site's base path so first-segment matching works on subdir installs. $otto_home_path = trim((string) parse_url(home_url(), PHP_URL_PATH), '/'); if ($otto_home_path !== '' && strpos($otto_req_path, $otto_home_path . '/') === 0) { $otto_req_path = substr($otto_req_path, strlen($otto_home_path) + 1); } $otto_first_segment = explode('/', $otto_req_path, 2)[0]; # Filterable so sites can add/remove transactional slugs without code changes. $otto_cart_segments = apply_filters('metasync_otto_cart_paths', array( 'cart', 'checkout', 'basket', 'request-a-quote', 'quote-request', )); $otto_cart_segments = array_map('strtolower', (array) $otto_cart_segments); if ($otto_first_segment !== '' && in_array($otto_first_segment, $otto_cart_segments, true)) { if (!defined('DONOTCACHEPAGE')) { define('DONOTCACHEPAGE', true); } return; } # ────────────────────────────────────────────────────────────────────── if ( # disable ajax calls isset($_GET['ucfrontajaxaction']) || # OTTO Preview mode - skip OTTO when previewing original content (isset($_GET['otto_preview']) && $_GET['otto_preview'] === '1') || # WooCommerce shop archive page only (products and categories now use SSR) //(function_exists('is_shop') && is_shop()) || # Cart page (function_exists('is_cart') && is_cart()) || # Checkout page (function_exists('is_checkout') && is_checkout()) || # My Account page //(function_exists('is_account_page') && is_account_page()) || # Standard WordPress AJAX (function_exists('wp_doing_ajax') && wp_doing_ajax()) || # check by constant (defined('DOING_AJAX') && DOING_AJAX) || # WooCommerce AJAX endpoint (e.g., ?wc-ajax=update_cart) (isset($_REQUEST['wc-ajax']) && !empty($_REQUEST['wc-ajax'])) || # AJAX requests via X-Requested-With header (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') || # Gravity Forms submission detection - skip OTTO to allow form processing (isset($_POST['gform_submit']) && ( is_array($_POST['gform_submit']) || (is_string($_POST['gform_submit']) && isset($_POST['is_submit_' . $_POST['gform_submit']]) && !empty($_POST['gform_submit'])) )) || # Gravity Forms AJAX submission (isset($_POST['gform_ajax']) && isset($_POST['gform_submit']) && ( is_array($_POST['gform_submit']) || !empty($_POST['gform_submit']) )) || # Gravity Forms file upload (isset($_POST['gform_uploaded_files'])) || # Any Gravity Forms POST parameter (isset($_POST['gform_submit']) || isset($_POST['gform_unique_id']) || isset($_POST['gform_field_values'])) || # Formidable Forms AJAX submission detection - skip OTTO to allow form processing (isset($_POST['action']) && $_POST['action'] === 'frm_entries_create') || # Formidable Forms POST parameters (isset($_POST['form_id']) && !empty($_POST['form_id'])) || # Formidable Forms action parameter (isset($_POST['frm_action']) && !empty($_POST['frm_action'])) || # Formidable Forms item_key (used in form submissions) (isset($_POST['item_key']) && !empty($_POST['item_key'])) ) { return; } # fetch globals global $metasync_options, $otto_enabled; # OPTIMIZED: check for the disable otto for logged in users option using cached config if(Metasync_Otto_Config::is_disabled_for_loggedin()){ # get user $current_user = wp_get_current_user(); # check if user is logged in if( !empty($current_user->ID)){ return; } } # check if current URL is manually excluded from OTTO # NOTE: auto-exclusions (false-positive 404 detections) are intentionally NOT checked # here — they must not block OTTO rendering. Use metasync_is_otto_url_excluded() only # in the webhook handler where we gate SEO meta writes. $request_uri = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? '')); $current_url = home_url(strtok($request_uri, '?') ?: $request_uri); if (metasync_is_otto_url_manually_excluded($current_url)) { return; } # Skip OTTO for XML endpoints (sitemaps, RSS-as-xml, etc.). OTTO has no # suggestions for machine-readable XML, and the upstream API returns # API_ERROR for these URLs — which then stamps misleading # X-MetaSync-OTTO-Cache: API_ERROR / X-MetaSync-OTTO-Method: NONE headers # onto otherwise-healthy sitemap responses and causes false-alarm bug # reports from customers. See WP-353. $request_path = strtok($request_uri, '?'); if ($request_path && preg_match('#\.xml$#i', $request_path)) { return; } # BOT DETECTION: Always detect bots so crawl data reaches the SA backend. # Real bots don't execute JS, so otto-tracker.js never fires for them. # push_crawl_log_to_sa() fires a non-blocking wp_remote_post here instead. $bot_detector = Metasync_Otto_Bot_Detector::get_instance(); $detection = $bot_detector->detect(); if ( $detection['is_bot'] ) { $bot_detector->push_crawl_log_to_sa( $detection, $current_url ); } # Throttle OTTO rendering for non-search-engine bots: at most one render # per URL+bot every 5 minutes. Humans and verified search engines # (Googlebot, Bingbot, etc.) are never throttled — their hits always get # full OTTO content so indexing remains current. SEO tools, AI scrapers, # uptime monitors, and unverified crawlers are de-duplicated so repeat # hits don't re-trigger the expensive DOM-rewriting path. if ( $detection['is_bot'] && $detection['bot_type'] !== 'search_engine' ) { $normalized_url = strtok( $current_url, '?' ); if ( $normalized_url === false ) { $normalized_url = $current_url; } // Lowercase + length cap so attacker-controlled bot_name variations // (EvilBota, EvilBotB, EvilBotc, ...) collapse to a bounded key space // and cannot flood wp_options with unique transient rows. $bot_name = substr( strtolower( $detection['bot_name'] ?? 'unknown' ), 0, 32 ); $render_throttle_key = 'metasync_otto_rendered_' . md5( $normalized_url . '|' . $bot_name ); if ( get_transient( $render_throttle_key ) ) { // Prevent page caches (WP Rocket, W3TC, etc.) from saving the // un-OTTO'd response and serving it to subsequent human visitors. if ( ! defined( 'DONOTCACHEPAGE' ) ) { define( 'DONOTCACHEPAGE', true ); } return; } set_transient( $render_throttle_key, 1, 5 * MINUTE_IN_SECONDS ); } # Optionally skip OTTO processing for bot traffic (when the setting is enabled) if ($bot_detector->should_skip_otto()) { // Log the bot locally and count the saved API call $bot_detector->log_detection($detection); $bot_stats_db = Metasync_Otto_Bot_Statistics_Database::get_instance(); $bot_stats_db->increment_api_calls_saved(); // Skip OTTO processing for this bot return; } # OPTIMIZED: Check if Otto should be disabled for WP Rocket compatibility if (class_exists('WP_Rocket')) { $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode(); if ($wp_rocket_compat_mode === 'disable_otto') { return; # Exit early, Otto is disabled when WP Rocket is active } } # Skip OTTO for Divi AJAX pagination and paginated archive requests (WP-315). # ?et_blog = Divi AJAX pagination callback # /page/N/ = paginated blog/archive pages — OTTO's buffer/HTTP render causes # module numbering mismatch between page 1 (with TB template) and page N # (without TB template), breaking Divi's JS pagination selector matching. if (isset($_GET['et_blog']) || (is_paged() && !is_singular())) { return; } # Handle cache plugin compatibility early - before any caching happens metasync_otto_handle_cache_compatibility(); # check if OTTO is disabled for this specific page/post $post_id = get_the_ID(); if ($post_id && class_exists('Metasync_Otto_Frontend_Toolbar')) { if (Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id)) { return; } } # check if we are having an otto request if(!empty($_GET['is_otto_page_fetch'])){ # Block SEO plugins NOW for this internal fetch request # metasync_otto_block_seo_plugins(); # $_SERVER['REQUEST_URI'] = remove_query_arg('is_otto_page_fetch', $_SERVER['REQUEST_URI']); $block_title = !empty($_GET['otto_block_title']) && $_GET['otto_block_title'] === '1'; $block_description = !empty($_GET['otto_block_desc']) && $_GET['otto_block_desc'] === '1'; # Block SEO plugins conditionally based on what Otto has if ($block_title || $block_description) { metasync_otto_block_seo_plugins($block_title, $block_description); } # Remove ALL Otto parameters from REQUEST_URI to prevent them from appearing in pagination, etc. $request_uri_raw = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? '')); $_SERVER['REQUEST_URI'] = remove_query_arg( ['is_otto_page_fetch', 'otto_block_title', 'otto_block_desc'], $request_uri_raw ); # Also remove from $_GET to prevent WordPress from using them unset($_GET['is_otto_page_fetch']); unset($_GET['otto_block_title']); unset($_GET['otto_block_desc']); return; } # to avoid unnecessary processese # OPTIMIZED: check that otto is configured # And the UUID is properly set before running OTT # check that we have the option if(!Metasync_Otto_Config::is_otto_enabled()){ return; } # check that otto is enabled if(!$otto_enabled){ return; } # get the otto uuid $otto_uuid = Metasync_Otto_Config::get_otto_uuid(); # start the class $otto = new Metasync_otto_pixel($otto_uuid); # call render $otto->render_route_html(); } /** * Handle cache plugin compatibility with Otto * Controls DONOTCACHEPAGE constant based on active plugins and configuration * This function is called early in the WordPress lifecycle */ function metasync_otto_handle_cache_compatibility() { # Detect active plugins $brizy_active = class_exists('Brizy_Editor') || defined('BRIZY_VERSION'); $wp_rocket_active = class_exists('WP_Rocket'); $sg_optimizer_active = is_plugin_active('sg-cachepress/sg-cachepress.php'); # OPTIMIZED: Get configuration option using cached config $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode(); # Check for Brizy posts in database global $wpdb; $has_brizy_posts = false; if ($brizy_active) { # OPTIMIZED: Check cache first (1-hour TTL) to avoid querying on every page load $cached = get_transient('metasync_has_brizy_posts'); if ($cached !== false) { $has_brizy_posts = ($cached === 'yes'); } else { # Query database only if cache missed $has_brizy_posts = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = 'brizy_post_uid' AND meta_value != '' LIMIT 1" ); # Cache result for 1 hour $result = !empty($has_brizy_posts) ? 'yes' : 'no'; set_transient('metasync_has_brizy_posts', $result, HOUR_IN_SECONDS); } } # Determine if DONOTCACHEPAGE should be set $should_set_donotcachepage = false; # Case 1: Brizy is active with posts - always needed if ($brizy_active && !empty($has_brizy_posts)) { $should_set_donotcachepage = true; } # Case 2: SG Optimizer without WP Rocket - prevent conflicts elseif ($sg_optimizer_active && !$wp_rocket_active) { $should_set_donotcachepage = true; } # Case 3: User explicitly disabled Otto for WP Rocket compatibility elseif ($wp_rocket_active && $wp_rocket_compat_mode === 'disable_otto') { $should_set_donotcachepage = true; return; # Exit early, Otto won't run } # Case 4: WP Rocket active with auto/buffer mode - DON'T set DONOTCACHEPAGE # This allows WP Rocket optimizations to continue working # Only set DONOTCACHEPAGE if needed if ($should_set_donotcachepage && !defined('DONOTCACHEPAGE')) { define('DONOTCACHEPAGE', true); } # Apply SG Optimizer specific filters only if SG is active and no WP Rocket if ($sg_optimizer_active && !$wp_rocket_active && $should_set_donotcachepage) { if (!defined('SG_CachePress_SUPERCACHER')) { define('SG_CachePress_SUPERCACHER', false); } add_filter('sgo_html_cache_disable', '__return_true', 999); add_filter('sgo_css_combine_exclude', '__return_true', 999); add_filter('sgo_js_combine_exclude', '__return_true', 999); add_filter('sgo_cache_this_page', '__return_false', 999); if (!headers_sent()) { header('Cache-Control: no-cache, must-revalidate, max-age=0'); header('X-Accel-Expires: 0'); } } } /** * Block SEO plugins conditionally based on what Otto is providing * Only blocks title if Otto has title, only blocks description if Otto has description * This prevents duplicate SEO tags while allowing fallback to SEO plugins when Otto has no data. * Supports Yoast SEO, Rank Math, and AIOSEO (free + pro). * * @param bool $block_title Whether to block title tags. * @param bool $block_description Whether to block description tags. * @param array $description_tags Optional. Granular list of OTTO-provided description tags * (e.g. ['meta[name=description]', 'meta[property=og:description]']). * When provided, only matching AIOSEO tags are suppressed. * When empty, all AIOSEO description tags are suppressed (legacy behavior). */ function metasync_otto_block_seo_plugins($block_title = false, $block_description = false, $description_tags = []) { # Disable Yoast SEO (free and premium) if (is_plugin_active('wordpress-seo/wp-seo.php') || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) { # TITLE: Never block Yoast's title output during SSR fetch. # Yoast removes WordPress's native _wp_render_title_tag action and is the sole # renderer of the tag. Returning false/empty from wpseo_title or removing # Title_Presenter leaves the page with NO <title> tag at all — OTTO's buffer # post-processing then has nothing to replace, producing a missing title. # Instead, let Yoast render its own title; OTTO's replace_title() will overwrite # it in the final HTML buffer. deduplicate_title_tags() cleans up any duplicates. # Block description only if Otto has description if ($block_description) { add_filter('wpseo_metadesc', '__return_false', 999); add_filter('wpseo_meta_description', '__return_false', 999); add_filter('wpseo_metakeywords', '__return_false', 999); } # Block Yoast's modern presenters — description only, never title add_filter('wpseo_frontend_presenters', function($presenters) use ($block_description) { if (!is_array($presenters)) return $presenters; $presenters_to_remove = []; # Remove description presenters only when OTTO has a description if ($block_description) { $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Meta_Description_Presenter'; $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Open_Graph\Description_Presenter'; $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Twitter\Description_Presenter'; } foreach ($presenters as $key => $presenter) { // Safely get class name, suppressing autoload errors // This prevents warnings when Composer autoloader tries to load deprecated Yoast files $class_name = is_object($presenter) ? @get_class($presenter) : ''; if (!empty($class_name) && in_array($class_name, $presenters_to_remove)) { unset($presenters[$key]); } } return $presenters; }, 999); } # Disable Rank Math if (is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php')) { if ($block_title) { add_filter('rank_math/frontend/title', '__return_empty_string', 999); } if ($block_description) { add_filter('rank_math/frontend/description', '__return_false', 999); add_filter('rank_math/frontend/show_keywords', '__return_false', 999); } } # Disable AIOSEO (free and pro) if (is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') || is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) { if ($block_title) { add_filter('aioseo_title', '__return_empty_string', 999); add_filter('aioseo_facebook_tags', function($meta) { if (is_array($meta)) { unset($meta['og:title']); } return $meta; }, 999); add_filter('aioseo_twitter_tags', function($meta) { if (is_array($meta)) { unset($meta['twitter:title']); } return $meta; }, 999); } if ($block_description) { # Use granular tag list when available to only block what OTTO provides $tags = !empty($description_tags) ? $description_tags : []; $block_standard = empty($tags) || in_array('meta[name=description]', $tags); $block_og_desc = empty($tags) || in_array('meta[property=og:description]', $tags); $block_tw_desc = empty($tags) || in_array('meta[name=twitter:description]', $tags); if ($block_standard) { add_filter('aioseo_description', '__return_empty_string', 999); } if ($block_og_desc) { add_filter('aioseo_facebook_tags', function($meta) { if (is_array($meta)) { unset($meta['og:description']); } return $meta; }, 999); } if ($block_tw_desc) { add_filter('aioseo_twitter_tags', function($meta) { if (is_array($meta)) { unset($meta['twitter:description']); } return $meta; }, 999); } } } } # check that otto is not added via js to the site function metasync_check_otto_js(){ # Cache result to avoid a blocking HTTP self-request (~2.4s) # on every single admin page load. $cache_key = 'metasync_otto_js_detected'; $cached = get_transient($cache_key); if ($cached !== false) { return $cached === 'yes'; } # get the site url $site_url = site_url() . '?is_otto_page_fetch=1'; # get the html — short timeout so admin doesn't hang $page_data = wp_remote_get($site_url, array('timeout' => 5, 'sslverify' => false)); if (is_wp_error($page_data)) { set_transient($cache_key, 'no', HOUR_IN_SECONDS); return false; } # now get the html body $body = wp_remote_retrieve_body($page_data); # now load the body into html $dom = new HtmlDocument($body); # now check the dom for a meta tag with $script = $dom->find('script#sa-dynamic-optimization', 0); # check script if($script AND !empty($script->getAttribute('data-uuid'))){ set_transient($cache_key, 'yes', 12 * HOUR_IN_SECONDS); return true; } set_transient($cache_key, 'no', 12 * HOUR_IN_SECONDS); return false; }; # Handle AJAX Clear Cache request # NOTE: Cache system removed - this is now a no-op function metasync_clear_otto_cache_handler() { if (!empty($_GET['clear_otto_cache'])) { delete_transient('metasync_otto_js_detected'); # Cache system has been removed - no cache to clear wp_send_json_success(['message' => 'Cache system removed - all pages processed in real-time']); } else { wp_send_json_error(['message' => 'Missing parameter']); } } # Clear cache hook add_action('wp_ajax_metasync_clear_otto_cache', 'metasync_clear_otto_cache_handler'); # add admin action to check script function metasync_show_otto_ssr_notice() { if (!Metasync::current_user_has_plugin_access()) { return; // Only show to admins } # Get the plugin name using centralized method $plugin_name = Metasync::get_effective_plugin_name(); $whitelabel_otto_name = Metasync::get_whitelabel_otto_name(); if (metasync_check_otto_js()) { # Show admin notice with plugin name included in the message echo '<div class="notice notice-error"> <p><b>Warning from ' . esc_html($plugin_name) . '</b> <br> ' . 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 </p> </div>'; } } add_action('admin_notices', 'metasync_show_otto_ssr_notice'); # staging dummy change # load otto in the wp hook add_action('wp', 'metasync_start_otto'); # ENHANCED OTTO SEO INTEGRATION # Register async SEO processing hook add_action('metasync_process_seo_job', 'metasync_process_otto_seo_data', 10, 3); add_action('metasync_process_otto_crawl_url_job', 'metasync_handle_otto_crawl_url_job', 10, 2); add_action('metasync_process_otto_batch_cache_job', 'metasync_handle_otto_batch_cache_job'); # Process OTTO SEO data and update WordPress meta fields for SEO plugins # This function now runs asynchronously via WordPress cron system # # @param string $route Fully-qualified URL to process. # @param bool $allow_defer When false, skip CPU-deferral rescheduling (used when # called synchronously from crawl_url_job whose own retry # mechanism already handles failures). # @param int $deferral_count How many times this job has already been deferred for # CPU load. Prevents infinite reschedule loops. function metasync_process_otto_seo_data($route, $allow_defer = true, $deferral_count = 0) { # Maximum number of times a job can be deferred before it is dropped. $max_deferrals = defined('METASYNC_SEO_JOB_MAX_DEFERRALS') ? METASYNC_SEO_JOB_MAX_DEFERRALS : 5; $lock_key = null; $lock_acquired = false; try { # Validate input if (empty($route) || !is_string($route)) { return false; } # CPU load check — defer if server is under load. # Only reschedule when called from the cron hook (allow_defer=true) and # we haven't exceeded the maximum deferral count. # Checked BEFORE acquiring the lock so that deferred jobs don't # acquire-then-immediately-release it (which defeats concurrency protection). if (!Metasync_CPU_Monitor::is_load_safe()) { if ($allow_defer && $deferral_count < $max_deferrals) { wp_schedule_single_event( time() + 60, 'metasync_process_seo_job', array($route, true, $deferral_count + 1) ); return false; } # Max deferrals reached or not allowed to defer — drop the job to # prevent unbounded cron accumulation that causes server overload. if ($deferral_count >= $max_deferrals) { metasync_record_failed_action('metasync_process_seo_job'); return false; } # allow_defer=false (sync path): just return false, parent handles retry. return false; } # Concurrency lock: prevent multiple SEO jobs from running simultaneously. # Uses a per-URL transient lock with a 120s TTL as a safety net (the lock is # explicitly deleted on completion). If the lock exists another run is already # processing this URL — reschedule once with a short delay instead of stacking. $lock_key = 'metasync_seo_lock_' . md5($route); if (get_transient($lock_key) !== false) { if ($allow_defer && $deferral_count < $max_deferrals) { wp_schedule_single_event( time() + 30, 'metasync_process_seo_job', array($route, true, $deferral_count + 1) ); } return false; } set_transient($lock_key, true, 120); $lock_acquired = true; # Resolve redirect table: use final destination URL before 404 checks and OTTO processing $route = metasync_otto_resolve_redirect_to_final_url($route); # Skip excluded URLs - don't process SEO data for them if (metasync_is_otto_url_excluded($route)) { //error_log('MetaSync OTTO: Skipping SEO processing for excluded URL: ' . $route); return false; } # Pre-flight 404 check: exclude URLs that would return 404 before making API call if (!metasync_otto_is_url_available($route)) { error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route}"); metasync_otto_auto_exclude_404_url($route); return false; } # Get OTTO UUID from settings # OPTIMIZED: Use cached options $otto_uuid = Metasync_Otto_Config::get_otto_uuid(); if (empty($otto_uuid)) { return false; } # Meta descriptions are always enabled by default - no check needed # Fetch SEO data from OTTO API $seo_data = metasync_fetch_otto_seo_data($route, $otto_uuid); if (!$seo_data) { metasync_record_failed_action( 'metasync_process_seo_job' ); return false; } # Mark this URL as crawled by OTTO for SSR # Extract domain and path from route $parsed_url = parse_url($route); $domain_with_scheme = ($parsed_url['scheme'] ?? 'https') . '://' . ($parsed_url['host'] ?? ''); $url_path = ($parsed_url['path'] ?? '/'); # Create crawl data structure $crawl_data = array( 'domain' => $domain_with_scheme, 'urls' => array($url_path) ); # Load Otto pixel class and save crawl data $otto_pixel = new Metasync_otto_pixel($otto_uuid); $otto_pixel->save_crawl_data($crawl_data); # Get WordPress post ID from URL $post_id = url_to_postid($route); # Special handling for WooCommerce shop page (url_to_postid doesn't work for it) if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) { # Check if this URL is the WooCommerce shop page $shop_page_id = wc_get_page_id('shop'); if ($shop_page_id > 0) { $shop_url = get_permalink($shop_page_id); $route_normalized = rtrim($route, '/'); $shop_url_normalized = rtrim($shop_url, '/'); if ($route_normalized === $shop_url_normalized) { $post_id = $shop_page_id; } } } # Try to find WooCommerce product by URL if url_to_postid failed if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) { # Extract product slug from URL $product_slug = basename(parse_url($route, PHP_URL_PATH)); # Try to get product by slug $products = wc_get_products(array( 'name' => $product_slug, 'limit' => 1, 'status' => 'publish', )); if (empty($products)) { # Fallback: try by slug using WP_Query $args = array( 'post_type' => 'product', 'name' => $product_slug, 'posts_per_page' => 1, 'post_status' => 'publish', ); $query = new WP_Query($args); if ($query->have_posts()) { $product_post = $query->posts[0]; $post_id = $product_post->ID; } } else { $product = $products[0]; $post_id = $product->get_id(); } } if (!$post_id || $post_id <= 0) { # Check if this is a category page if (strpos($route, '/category/') !== false) { # Extract category slug from URL $category_slug = basename(parse_url($route, PHP_URL_PATH)); $category = get_category_by_slug($category_slug); if ($category) { # Check if category would return 404 before applying OTTO changes if (metasync_would_term_return_404($category->term_id, 'category', $route)) { error_log("MetaSync OTTO: Skipping SEO processing for category that would return 404: {$route} (Category ID: {$category->term_id})"); metasync_otto_auto_exclude_404_url($route); return false; } # Update comprehensive category SEO meta fields $update_result = metasync_update_comprehensive_category_seo_fields($category->term_id, $seo_data); if ($update_result['updated']) { # Prepare trimmed values to 30 characters $trim = function($value) { if ($value === null) { return ''; } $value = (string) $value; $value = trim($value); if (mb_strlen($value) > 30) { return mb_substr($value, 0, 30); } return $value; }; # Log individual field updates for category foreach ($update_result['fields_updated'] as $field_type => $field_value) { $short = ''; $title = ''; switch ($field_type) { case 'meta_title': $short = $trim($field_value); $title = "Category Meta Title Update ({$short}...)"; break; case 'meta_description': $short = $trim($field_value); $title = "Category Meta Description Update ({$short}...)"; break; case 'meta_keywords': $short = $trim($field_value); $title = "Category Meta Keywords Update ({$short}...)"; break; case 'og_title': $short = $trim($field_value); $title = "Category Open Graph Title Update ({$short}...)"; break; case 'og_description': $short = $trim($field_value); $title = "Category Open Graph Description Update ({$short}...)"; break; case 'twitter_title': $short = $trim($field_value); $title = "Category Twitter Title Update ({$short}...)"; break; case 'twitter_description': $short = $trim($field_value); $title = "Category Twitter Description Update ({$short}...)"; break; case 'image_alt_data': $image_count = count($field_value); $title = "Category Image Alt Text Update ({$image_count} images)"; break; case 'headings_data': $heading_count = count($field_value); $title = "Category Headings Update ({$heading_count} headings)"; break; case 'structured_data': $title = 'Category Structured Data Update'; break; } if (!empty($title)) { metasync_log_sync_history([ 'title' => $title, 'source' => 'OTTO SEO', 'status' => 'published', 'content_type' => 'Category SEO', 'url' => $route, 'meta_data' => json_encode([ 'field' => $field_type, 'field_value' => $field_value, 'category_id' => $category->term_id, 'category_name' => $category->name ]) ]); } } return true; } return false; } } # Check if this is a WooCommerce product category if (strpos($route, '/product-category/') !== false) { # Extract product category slug from URL $category_slug = basename(parse_url($route, PHP_URL_PATH)); $term = get_term_by('slug', $category_slug, 'product_cat'); if ($term && !is_wp_error($term)) { # Check if product category would return 404 before applying OTTO changes if (metasync_would_term_return_404($term->term_id, 'product_cat', $route)) { error_log("MetaSync OTTO: Skipping SEO processing for product category that would return 404: {$route} (Term ID: {$term->term_id})"); metasync_otto_auto_exclude_404_url($route); return false; } # Update comprehensive taxonomy SEO meta fields $update_result = metasync_update_comprehensive_taxonomy_seo_fields($term->term_id, 'product_cat', $seo_data); if ($update_result['updated']) { # Prepare trimmed values to 30 characters $trim = function($value) { if ($value === null) { return ''; } $value = (string) $value; $value = trim($value); if (mb_strlen($value) > 30) { return mb_substr($value, 0, 30); } return $value; }; # Log individual field updates for product category foreach ($update_result['fields_updated'] as $field_type => $field_value) { $short = ''; $title = ''; switch ($field_type) { case 'meta_title': $short = $trim($field_value); $title = "Product Category Meta Title Update ({$short}...)"; break; case 'meta_description': $short = $trim($field_value); $title = "Product Category Meta Description Update ({$short}...)"; break; case 'meta_keywords': $short = $trim($field_value); $title = "Product Category Meta Keywords Update ({$short}...)"; break; case 'og_title': $short = $trim($field_value); $title = "Product Category Open Graph Title Update ({$short}...)"; break; case 'og_description': $short = $trim($field_value); $title = "Product Category Open Graph Description Update ({$short}...)"; break; case 'twitter_title': $short = $trim($field_value); $title = "Product Category Twitter Title Update ({$short}...)"; break; case 'twitter_description': $short = $trim($field_value); $title = "Product Category Twitter Description Update ({$short}...)"; break; case 'image_alt_data': $image_count = count($field_value); $title = "Product Category Image Alt Text Update ({$image_count} images)"; break; case 'headings_data': $heading_count = count($field_value); $title = "Product Category Headings Update ({$heading_count} headings)"; break; case 'structured_data': $title = 'Product Category Structured Data Update'; break; } if (!empty($title)) { metasync_log_sync_history([ 'title' => $title, 'source' => 'OTTO SEO', 'status' => 'published', 'content_type' => 'WooCommerce Product Category SEO', 'url' => $route, 'meta_data' => json_encode([ 'field' => $field_type, 'field_value' => $field_value, 'term_id' => $term->term_id, 'term_name' => $term->name, 'taxonomy' => 'product_cat' ]) ]); } } return true; } return false; } } # Check if this is the home page (landing page) $site_url = rtrim(site_url(), '/'); $route_clean = rtrim($route, '/'); if ($route_clean === $site_url) { # Get the home page (front page) $front_page_id = get_option('page_on_front'); $home_page = null; if ($front_page_id && $front_page_id > 0) { $home_page = get_post($front_page_id); } else { # If no static front page is set, get the latest post $home_page = get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null; } if ($home_page) { # Check if home page would return 404 before applying OTTO changes if (metasync_would_page_return_404($home_page->ID, $route)) { 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})"); metasync_otto_auto_exclude_404_url($route); return false; } # Update comprehensive home page SEO meta fields $update_result = metasync_update_comprehensive_seo_fields($home_page->ID, $seo_data); if ($update_result['updated']) { # Clear relevant caches metasync_clear_post_seo_caches($home_page->ID); # Prepare trimmed values to 30 characters $trim = function($value) { if ($value === null) { return ''; } $value = (string) $value; $value = trim($value); if (mb_strlen($value) > 30) { return mb_substr($value, 0, 30); } return $value; }; # Log individual field updates for home page foreach ($update_result['fields_updated'] as $field_type => $field_value) { $short = ''; $title = ''; switch ($field_type) { case 'meta_title': $short = $trim($field_value); $title = "Home Page Meta Title Update ({$short}...)"; break; case 'meta_description': $short = $trim($field_value); $title = "Home Page Meta Description Update ({$short}...)"; break; case 'meta_keywords': $short = $trim($field_value); $title = "Home Page Meta Keywords Update ({$short}...)"; break; case 'og_title': $short = $trim($field_value); $title = "Home Page Open Graph Title Update ({$short}...)"; break; case 'og_description': $short = $trim($field_value); $title = "Home Page Open Graph Description Update ({$short}...)"; break; case 'twitter_title': $short = $trim($field_value); $title = "Home Page Twitter Title Update ({$short}...)"; break; case 'twitter_description': $short = $trim($field_value); $title = "Home Page Twitter Description Update ({$short}...)"; break; case 'image_alt_data': $image_count = count($field_value); $title = "Home Page Image Alt Text Update ({$image_count} images)"; break; case 'headings_data': $heading_count = count($field_value); $title = "Home Page Headings Update ({$heading_count} headings)"; break; case 'structured_data': $title = 'Home Page Structured Data Update'; break; } if (!empty($title)) { metasync_log_sync_history([ 'title' => $title, 'source' => 'OTTO SEO', 'status' => 'published', 'content_type' => 'Home Page SEO', 'url' => $route, 'meta_data' => json_encode([ 'field' => $field_type, 'field_value' => $field_value, 'post_id' => $home_page->ID ]) ]); } } return true; } return false; } } # Check if this is the blog/posts page (page_for_posts) $posts_page_id = intval(get_option('page_for_posts')); if ($posts_page_id > 0) { $posts_page = get_post($posts_page_id); $posts_page_url = rtrim(get_permalink($posts_page_id), '/'); if ($posts_page && $route_clean === $posts_page_url) { # Check if blog page would return 404 if (metasync_would_page_return_404($posts_page->ID, $route)) { 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})"); metasync_otto_auto_exclude_404_url($route); return false; } # Update comprehensive blog page SEO meta fields $update_result = metasync_update_comprehensive_seo_fields($posts_page->ID, $seo_data); if ($update_result['updated']) { # Clear relevant caches metasync_clear_post_seo_caches($posts_page->ID); # Prepare trimmed values to 30 characters $trim = function($value) { if ($value === null) { return ''; } $value = (string) $value; $value = trim($value); if (mb_strlen($value) > 30) { return mb_substr($value, 0, 30); } return $value; }; # Log individual field updates for blog page foreach ($update_result['fields_updated'] as $field_type => $field_value) { $short = ''; $title = ''; switch ($field_type) { case 'meta_title': $short = $trim($field_value); $title = "Blog Page Meta Title Update ({$short}...)"; break; case 'meta_description': $short = $trim($field_value); $title = "Blog Page Meta Description Update ({$short}...)"; break; case 'meta_keywords': $short = $trim($field_value); $title = "Blog Page Meta Keywords Update ({$short}...)"; break; case 'og_title': $short = $trim($field_value); $title = "Blog Page Open Graph Title Update ({$short}...)"; break; case 'og_description': $short = $trim($field_value); $title = "Blog Page Open Graph Description Update ({$short}...)"; break; case 'twitter_title': $short = $trim($field_value); $title = "Blog Page Twitter Title Update ({$short}...)"; break; case 'twitter_description': $short = $trim($field_value); $title = "Blog Page Twitter Description Update ({$short}...)"; break; case 'image_alt_data': $image_count = count($field_value); $title = "Blog Page Image Alt Text Update ({$image_count} images)"; break; case 'headings_data': $heading_count = count($field_value); $title = "Blog Page Headings Update ({$heading_count} headings)"; break; case 'structured_data': $title = 'Blog Page Structured Data Update'; break; } if (!empty($title)) { metasync_log_sync_history([ 'title' => $title, 'source' => 'OTTO SEO', 'status' => 'published', 'content_type' => 'Blog Page SEO', 'url' => $route, 'meta_data' => json_encode([ 'field' => $field_type, 'field_value' => $field_value, 'post_id' => $posts_page->ID ]) ]); } } return true; } return false; } } # URL didn't resolve to any supported entity (post, category, home page, blog page) # Treat as 404 and auto-exclude (e.g. deleted post, non-existent page) if (!metasync_otto_is_url_available($route)) { error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404 (no matching entity): {$route}"); metasync_otto_auto_exclude_404_url($route); } return false; } # Verify this is actually a post, page, or WooCommerce product $post = get_post($post_id); # Get supported post types dynamically $supported_post_types = metasync_get_supported_post_types(); if (!$post || !in_array($post->post_type, $supported_post_types)) { # Skip unsupported post types return false; } # Check if page would return 404 before applying OTTO changes if (metasync_would_page_return_404($post_id, $route)) { error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route} (Post ID: {$post_id}, Status: {$post->post_status})"); metasync_otto_auto_exclude_404_url($route); return false; } # Update comprehensive SEO meta fields $update_result = metasync_update_comprehensive_seo_fields($post_id, $seo_data); if ($update_result['updated']) { # Clear relevant caches metasync_clear_post_seo_caches($post_id); # Prepare trimmed values to 30 characters $trim = function($value) { if ($value === null) { return ''; } $value = (string) $value; $value = trim($value); if (mb_strlen($value) > 30) { return mb_substr($value, 0, 30); } return $value; }; # Log individual field updates foreach ($update_result['fields_updated'] as $field_type => $field_value) { $short = ''; $title = ''; switch ($field_type) { case 'meta_title': $short = $trim($field_value); $title = 'Meta Title Update (' . $short . '...)'; break; case 'meta_description': $short = $trim($field_value); $title = 'Meta Description Update (' . $short . '...)'; break; case 'meta_keywords': $short = $trim($field_value); $title = 'Meta Keywords Update (' . $short . '...)'; break; case 'og_title': $short = $trim($field_value); $title = 'Open Graph Title Update (' . $short . '...)'; break; case 'og_description': $short = $trim($field_value); $title = 'Open Graph Description Update (' . $short . '...)'; break; case 'twitter_title': $short = $trim($field_value); $title = 'Twitter Title Update (' . $short . '...)'; break; case 'twitter_description': $short = $trim($field_value); $title = 'Twitter Description Update (' . $short . '...)'; break; case 'image_alt_data': $image_count = count($field_value); $title = "Image Alt Text Update ({$image_count} images)"; break; case 'headings_data': $heading_count = count($field_value); $title = "Headings Update ({$heading_count} headings)"; break; case 'structured_data': $title = 'Structured Data Update'; break; } if (!empty($title)) { metasync_log_sync_history([ 'title' => $title, 'source' => 'OTTO SEO', 'status' => 'published', 'content_type' => 'SEO Meta', 'url' => $route, 'meta_data' => json_encode([ 'field' => $field_type, 'field_value' => $field_value, 'post_id' => $post_id ]) ]); } } return true; } return false; } catch (Exception $e) { metasync_record_failed_action( 'metasync_process_seo_job' ); return false; } finally { # Release the concurrency lock only if we actually acquired it. # Early returns (CPU deferral, lock contention) must NOT delete a lock # that another process may be holding. if ($lock_acquired && $lock_key) { delete_transient($lock_key); } } } /** * Log sync history entry * @param array $data Sync data to log */ function metasync_log_sync_history($data) { try { // Classes are now autoloaded, no need for manual require $sync_db = new Metasync_Sync_History_Database(); // Minimal duplicate prevention within short time window if (!empty($data['title']) && !empty($data['source'])) { global $wpdb; $table = $wpdb->prefix . Metasync_Sync_History_Database::$table_name; $recent = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM `$table` WHERE title = %s AND source = %s AND created_at >= %s", $data['title'], $data['source'], gmdate('Y-m-d H:i:s', time() - 60) )); if ((int)$recent > 0) { return; // skip duplicate log within 60 seconds } } $sync_db->add($data); } catch (Exception $e) { error_log("MetaSync: Failed to log sync history: " . $e->getMessage()); } } /** * Get supported post types for OTTO SEO optimization * Includes WooCommerce products if WooCommerce is active * * @return array List of supported post types */ function metasync_get_supported_post_types() { # Start with default post types $post_types = ['post', 'page']; # Add WooCommerce product post type if WooCommerce is active if (class_exists('WooCommerce') || function_exists('is_woocommerce')) { $post_types[] = 'product'; } # Include all public custom post types (e.g. 'location', 'service', 'team', etc.) # so OTTO can write post meta for them during metasync_process_otto_seo_data(). $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names'); if (!empty($custom_post_types)) { $post_types = array_merge($post_types, array_values($custom_post_types)); } # Allow developers to filter supported post types $post_types = apply_filters('metasync_otto_supported_post_types', $post_types); return $post_types; } /** * Get supported taxonomies for OTTO SEO optimization * Includes WooCommerce product categories and tags if WooCommerce is active * * @return array List of supported taxonomies */ function metasync_get_supported_taxonomies() { # Start with default taxonomies $taxonomies = ['category']; # Add WooCommerce taxonomies if WooCommerce is active if (class_exists('WooCommerce') || function_exists('is_woocommerce')) { $taxonomies[] = 'product_cat'; # WooCommerce product categories $taxonomies[] = 'product_tag'; # WooCommerce product tags } # Allow developers to filter supported taxonomies $taxonomies = apply_filters('metasync_otto_supported_taxonomies', $taxonomies); return $taxonomies; } /** * Resolve a URL through the Redirect Manager table to its final destination (follows redirect chains). * Used before 404 checks and OTTO processing so the final canonical URL is used, not intermediate redirects. * * @param string $url Full URL (e.g. https://example.com/old-page) * @return string Final destination URL, or original $url if no redirect matches */ function metasync_otto_resolve_redirect_to_final_url($url) { if (empty($url) || !is_string($url)) { return $url; } try { $db_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection-database.php'; $class_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection.php'; if (!file_exists($db_path) || !file_exists($class_path)) { return $url; } require_once $db_path; require_once $class_path; $db = new Metasync_Redirection_Database(); $redirect = new Metasync_Redirection($db); return $redirect->resolve_url_to_final_destination($url, 10); } catch (Exception $e) { error_log('MetaSync OTTO: Redirect resolution failed for ' . $url . ' - ' . $e->getMessage()); return $url; } } /** * Auto-exclude a URL from OTTO with description "Auto-excluded: 404" * Called when a URL is detected as returning 404 so it won't be sent to OTTO again * * @param string $url Full URL to exclude (e.g. https://example.com/404-page) * @return bool|string True on success, false on failure, 'duplicate'/'reactivated' if already exists */ function metasync_otto_auto_exclude_404_url($url) { if (empty($url) || !is_string($url)) { return false; } $url = filter_var($url, FILTER_SANITIZE_URL); $url = esc_url_raw($url); if (empty($url) || mb_strlen($url) > 2048) { return false; } try { require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php'; $db = new Metasync_Otto_Excluded_URLs_Database(); return $db->add([ 'url_pattern' => $url, 'pattern_type' => 'exact', 'description' => 'Auto-excluded: 404', 'status' => 'active', 'auto_excluded' => 1, ]); } catch (Exception $e) { error_log('MetaSync OTTO: Failed to auto-exclude 404 URL: ' . $url . ' - ' . $e->getMessage()); return false; } } /** * Remove a URL from the OTTO auto-exclusion list. * Called when OTTO sends a webhook for a URL, confirming it is valid and crawlable. * Only removes records where auto_excluded = 1 (never removes manual exclusions). * * @param string $url Full URL to un-exclude (e.g. https://example.com/location/page) * @return bool True on success */ function metasync_otto_remove_auto_exclusion($url) { if (empty($url) || !is_string($url)) { return false; } try { require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php'; $db = new Metasync_Otto_Excluded_URLs_Database(); global $wpdb; $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name; # Normalize the same way is_url_excluded() does $url_normalized = rtrim(trim($url), '/'); $records = $wpdb->get_results( $wpdb->prepare( "SELECT id FROM `{$table}` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'", $url_normalized ) ); if (!empty($records)) { $ids = array_map(function ($r) { return (int) $r->id; }, $records); $db->delete($ids); } return true; } catch (Exception $e) { return false; } } /** * Check if a URL is MANUALLY excluded from OTTO (auto_excluded = 0). * Used at render time (metasync_start_otto) — auto-exclusions must NOT block * rendering because they are often false positives (e.g. custom post types that * url_to_postid() can't resolve). Auto-exclusions are only used to gate the * SEO meta-writing webhook path. * * @param string $url URL to check * @return bool True if URL has a manual exclusion */ function metasync_is_otto_url_manually_excluded($url) { if (empty($url) || !is_string($url)) { return false; } try { require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php'; global $wpdb; $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name; $url_normalized = rtrim(trim($url), '/'); $records = get_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY); if ($records === false) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- no user input, table name from $wpdb->prefix $records = $wpdb->get_results( "SELECT url_pattern, pattern_type FROM `{$table}` WHERE status = 'active' AND (auto_excluded = 0 OR auto_excluded IS NULL) ORDER BY created_at DESC" ); // Graceful recovery: auto_excluded column missing on pre-v2.7.4 installs. // Run ALTER TABLE to add it and treat URL as not excluded so OTTO continues rendering. if ($wpdb->last_error && strpos($wpdb->last_error, 'auto_excluded') !== false) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $wpdb->query("ALTER TABLE `{$table}` ADD COLUMN `auto_excluded` TINYINT(1) NOT NULL DEFAULT 0"); return false; } set_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY, $records ?: [], METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL); } if (empty($records)) { return false; } foreach ($records as $excluded) { $pattern = rtrim(trim($excluded->url_pattern), '/'); $pattern_type = $excluded->pattern_type; switch ($pattern_type) { case 'exact': if ($url_normalized === $pattern) { return true; } break; case 'contain': if (strpos($url_normalized, $pattern) !== false) { return true; } break; case 'start': if (strpos($url_normalized, $pattern) === 0) { return true; } break; } } return false; } catch (Exception $e) { return false; } } /** * Check if a URL is excluded from OTTO * @param string $url URL to check * @return bool True if URL is excluded, false otherwise */ function metasync_is_otto_url_excluded($url) { try { // Load database class require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php'; $db = new Metasync_Otto_Excluded_URLs_Database(); // Check if URL is excluded return $db->is_url_excluded($url); } catch (Exception $e) { return false; } } /** * Check if a URL is now available (would NOT return 404) * Uses same resolution logic as metasync_process_otto_seo_data * Used when rechecking auto-excluded 404 URLs after 7 days * * @param string $url Full URL to check (e.g. https://example.com/page) * @return bool True if URL is accessible, false if it would return 404 */ function metasync_otto_is_url_available($url) { if (empty($url) || !is_string($url)) { return false; } $route = metasync_otto_resolve_redirect_to_final_url($url); $post_id = url_to_postid($route); # WooCommerce shop page if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) { $shop_page_id = wc_get_page_id('shop'); if ($shop_page_id > 0) { $shop_url = get_permalink($shop_page_id); if (rtrim($route, '/') === rtrim($shop_url, '/')) { $post_id = $shop_page_id; } } } # WooCommerce product by slug if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) { $product_slug = basename(parse_url($route, PHP_URL_PATH)); $products = wc_get_products(array('name' => $product_slug, 'limit' => 1, 'status' => 'publish')); if (!empty($products)) { $post_id = $products[0]->get_id(); } else { $query = new WP_Query(array( 'post_type' => 'product', 'name' => $product_slug, 'posts_per_page' => 1, 'post_status' => 'publish', )); if ($query->have_posts()) { $post_id = $query->posts[0]->ID; } } } if ($post_id && $post_id > 0) { $post = get_post($post_id); # Accept any post type — custom post types (e.g. 'location', 'service') are valid URLs. # The old in_array check against metasync_get_supported_post_types() caused CPT URLs # to be wrongly auto-excluded as "404" pages. if ($post) { return !metasync_would_page_return_404($post_id, $route); } } # Category if (strpos($route, '/category/') !== false) { $category_slug = basename(parse_url($route, PHP_URL_PATH)); $category = get_category_by_slug($category_slug); if ($category) { return !metasync_would_term_return_404($category->term_id, 'category', $route); } } # WooCommerce product category if (strpos($route, '/product-category/') !== false) { $category_slug = basename(parse_url($route, PHP_URL_PATH)); $term = get_term_by('slug', $category_slug, 'product_cat'); if ($term && !is_wp_error($term)) { return !metasync_would_term_return_404($term->term_id, 'product_cat', $route); } } # Home page if (rtrim($route, '/') === rtrim(site_url(), '/')) { $front_page_id = get_option('page_on_front'); $home_page = ($front_page_id && $front_page_id > 0) ? get_post($front_page_id) : (get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null); if ($home_page) { return !metasync_would_page_return_404($home_page->ID, $route); } } # Could not verify availability from local data (custom archive, paginated page, etc.). # Assume the URL IS available — OTTO only crawls reachable URLs, so if we can't # prove it's a 404, we should not auto-exclude it. return true; } /** * Recheck auto-excluded 404 URLs when recheck_after has passed; remove from exclusion if now available * Uses recheck_after timestamp (default 7 days from exclusion) to decide when to recheck * Mark as permanent after 30 days if still 404 (no further rechecks) * Called by daily cron job */ function metasync_otto_recheck_404_exclusions() { try { require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php'; $db = new Metasync_Otto_Excluded_URLs_Database(); $records = $db->get_auto_excluded_404_urls_due_for_recheck(); if (empty($records)) { return; } $removed = 0; $marked_permanent = 0; $thirty_days_ago = strtotime('-30 days'); $next_recheck = date('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS); foreach ($records as $record) { $url = trim($record->url_pattern); if (empty($url)) { continue; } if (metasync_otto_is_url_available($url)) { $db->delete([$record->id]); $removed++; } else { # Still 404: if excluded 30+ days ago, mark as permanent (no more rechecks) $created_ts = strtotime($record->created_at); if ($created_ts <= $thirty_days_ago) { $db->update(['is_permanent' => 1], $record->id); $marked_permanent++; } else { # Schedule next recheck in 7 days $db->update(['recheck_after' => $next_recheck], $record->id); } } } if ($removed > 0) { error_log("MetaSync OTTO: Recheck 404 exclusions - removed {$removed} URL(s) that are now available"); } if ($marked_permanent > 0) { error_log("MetaSync OTTO: Recheck 404 exclusions - marked {$marked_permanent} URL(s) as permanent (still 404 after 30 days)"); } } catch (Exception $e) { error_log('MetaSync OTTO: Recheck 404 exclusions failed - ' . $e->getMessage()); } } add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions'); /** * Check if a post/page would return 404 without making HTTP request * Uses WordPress database checks for fast validation * * @param int $post_id WordPress post ID * @param string $url The URL being checked (optional, for logging) * @return bool True if page would return 404, false if accessible */ function metasync_would_page_return_404($post_id, $url = '') { if (!$post_id || $post_id <= 0) { return true; // No post ID = 404 } # Get the post object $post = get_post($post_id); if (!$post) { return true; // Post doesn't exist = 404 } # 1. Check post status - must be 'publish' to be publicly accessible if ($post->post_status !== 'publish') { return true; // Draft, pending, private, etc. = 404 } # 2. Check if post is password protected (requires password to view) if (!empty($post->post_password)) { # Password protected posts are not publicly accessible without password return true; // Password protected = effectively 404 for public } # 3. Check if post is in trash if ($post->post_status === 'trash') { return true; // Trashed = 404 } # 4. Check if post type is publicly queryable $post_type_object = get_post_type_object($post->post_type); if ($post_type_object && !$post_type_object->publicly_queryable) { # Some post types might not be publicly accessible # But we allow if it's in our supported types $supported_post_types = metasync_get_supported_post_types(); if (!in_array($post->post_type, $supported_post_types)) { return true; // Not publicly queryable = 404 } } # 5. WordPress 5.7+ has a built-in function for this if (function_exists('is_post_publicly_viewable')) { if (!is_post_publicly_viewable($post)) { return true; // Not publicly viewable = 404 } } # 6. Check if post is scheduled for future (not yet published) if ($post->post_date > current_time('mysql')) { return true; // Future post = 404 until publish date } # All checks passed - page should be accessible return false; } /** * Check if a taxonomy term (category, tag, etc.) would return 404 * Uses WordPress database checks for fast validation * * @param int $term_id Term ID * @param string $taxonomy Taxonomy name (e.g., 'category', 'product_cat') * @param string $url The URL being checked (optional, for logging) * @return bool True if term would return 404, false if accessible */ function metasync_would_term_return_404($term_id, $taxonomy, $url = '') { if (!$term_id || $term_id <= 0 || empty($taxonomy)) { return true; // Invalid term = 404 } # Get the term object $term = get_term($term_id, $taxonomy); if (is_wp_error($term) || !$term) { return true; // Term doesn't exist = 404 } # Check if taxonomy is publicly queryable $taxonomy_object = get_taxonomy($taxonomy); if (!$taxonomy_object || !$taxonomy_object->public) { # Check if it's in our supported taxonomies $supported_taxonomies = metasync_get_supported_taxonomies(); if (!in_array($taxonomy, $supported_taxonomies)) { return true; // Not publicly queryable = 404 } } # Terms are generally always accessible if they exist and taxonomy is public # WordPress doesn't have a "draft" status for terms like posts do # But we can check if the term has a count (has posts assigned) # Empty terms might not be useful, but they're still accessible # All checks passed - term should be accessible return false; } /** * Invalidate Brizy posts cache when posts are saved * OPTIMIZATION: Clears transient cache to ensure accurate detection */ add_action('save_post', function($post_id) { # Check if this post has Brizy metadata if (get_post_meta($post_id, 'brizy_post_uid', true)) { delete_transient('metasync_has_brizy_posts'); } }, 10, 1);