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

1,838 lines 76.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * This handles the OTTO SSR
4 */
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit;
7 }
8
9 # uses the simple html dom library
10 use simplehtmldom\HtmlDocument;
11
12 # include the otto class file
13 require_once plugin_dir_path( __FILE__ ) . '/vendor/autoload.php';
14 require_once plugin_dir_path( __FILE__ ) . '/Otto_html_class.php';
15 require_once plugin_dir_path( __FILE__ ) . '/Otto_pixel_class.php';
16 require_once plugin_dir_path( __FILE__ ) . '/metasync-otto-seo-functions.php';
17 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-transient-cache.php';
18 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-render-strategy.php';
19 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-config.php';
20 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-detector.php';
21 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-statistics-database.php';
22
23 # OPTIMIZED: get the metasync options (cached in static class)
24 $metasync_options = Metasync_Otto_Config::get_options();
25
26 # OTTO SSR is always enabled by default
27 $otto_enabled = true;
28
29 # add tag to wp head
30 add_action('wp_head', function(){
31 # load globals
32 global $metasync_options, $otto_enabled;
33
34 # OTTO SSR is always enabled
35 $string_enabled = 'true';
36
37 # OPTIMIZED: check uuid set using cached config
38 if(!Metasync_Otto_Config::is_otto_enabled()){
39 return;
40 }
41
42 # Performance optimization: Add DNS prefetch and preconnect for OTTO API
43 # This improves connection speed by resolving DNS and establishing connections early
44 # Use endpoint manager to get the correct domain
45 $otto_domain = 'sa.searchatlas.com'; # default
46 if (class_exists('Metasync_Endpoint_Manager')) {
47 $otto_api_domain = Metasync_Endpoint_Manager::get_endpoint('OTTO_API_DOMAIN');
48 $parsed = parse_url($otto_api_domain);
49 if (!empty($parsed['host'])) {
50 $otto_domain = $parsed['host'];
51 }
52 }
53 echo '<link rel="dns-prefetch" href="//' . esc_attr($otto_domain) . '">' . "\n";
54 echo '<link rel="preconnect" href="https://' . esc_attr($otto_domain) . '" crossorigin>' . "\n";
55
56 # adding the otto tag to pages
57 $plugin_version = defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown';
58 # OPTIMIZED: use cached uuid
59 $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).'">';
60
61 # out the otto tag
62 echo $otto_tag;
63 }, 1); # Priority 1 to output early in head
64
65 /**
66 * Start end point to handle requests on page updates
67 * The Otto Crawler will call this end point once a page is updated
68 **/
69
70 # function to register the route
71 function metasync_otto_crawl_notify($request){
72
73 # get request data params
74 $data = $request->get_json_params();
75
76 # fields
77 $fields = ['domain', 'urls'];
78
79 # validate the json request
80 foreach ($fields as $key => $value) {
81
82 # if the value is empty stop tehre
83 if(empty($data[$value])){
84
85 # Handle the POST request
86 return new WP_REST_Response(array(
87 'success' => false,
88 'message' => 'Invalid Field : '. $value,
89 ), 400);
90 }
91
92 }
93
94 # load otto pixel
95 $otto_pixel = new Metasync_otto_pixel(false);
96
97 # save the otto data first
98 $otto_pixel->save_crawl_data($data);
99
100 # Collect URLs that need cache warming after purge
101 $urls_to_warm = array();
102
103 # now we have the data sets
104 foreach($data['urls'] AS $key => $url){
105 # prepare the route
106 $route = $data['domain'] . $url;
107
108 # validate the route
109 $route = rtrim($route, '/');
110
111 # Resolve redirect table: use final destination URL before excluded/404 checks and OTTO processing
112 $route = metasync_otto_resolve_redirect_to_final_url($route);
113
114 # OTTO has confirmed this URL is crawlable. Remove any auto-exclusion that was
115 # previously set (e.g. because url_to_postid() returned 0 for a custom post type
116 # and metasync_otto_is_url_available() incorrectly treated it as a 404).
117 # Manual exclusions (auto_excluded = 0) are left untouched.
118 metasync_otto_remove_auto_exclusion($route);
119
120 # Skip manually excluded URLs - don't process OTTO data for them
121 if (metasync_is_otto_url_excluded($route)) {
122 continue;
123 }
124
125 # Step 1: Warm OTTO transient cache (fetch fresh suggestions from OTTO API into WP transient)
126 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
127 if (!empty($otto_uuid)) {
128 $transient_cache = new Metasync_Otto_Transient_Cache($otto_uuid);
129 $transient_cache->warm_cache($route);
130 }
131
132 # Step 2: Write SEO post meta synchronously BEFORE the cache purge.
133 # This ensures the DB is fully up-to-date when Kinsta (or any host) re-populates
134 # the cache on the very next request — eliminating the race condition where the
135 # 1-second scheduled job hadn't run yet and stale meta got cached.
136 metasync_process_otto_seo_data($route);
137
138 # Step 3: Clear the per-URL cache entry
139 $otto_pixel->refresh_cache($route);
140 Metasync_Cache_Purge::purge_single_url($route);
141
142 $urls_to_warm[] = $route;
143 }
144
145 # Step 4: Re-populate the cache for each affected URL.
146 # purge_single_url() above cleared WP Rocket + Kinsta per-URL.
147 # warm_urls() hits each URL now so our code — with fresh transients and post meta —
148 # is first to populate the host cache. Kinsta stores the OTTO-modified version
149 # before any real visitor or external bot arrives.
150 # A full-site purge (purge_all) is intentionally absent: it was nuking the entire
151 # Kinsta cache for 1–3 URL updates, leaving all other pages cold.
152 Metasync_Cache_Purge::warm_urls($urls_to_warm);
153
154 # Step 5: Purge edge CDN caches (Cloudflare, Fastly, Akamai, Sucuri, Sevalla, etc.)
155 # Tag-based providers purge only the affected posts; full-flush providers fire once per batch.
156 Metasync_Edge_Cache_Purge::purge($urls_to_warm);
157
158 # Track OTTO optimization event in GA4
159 try {
160 Metasync_GA4::get_instance()->track_otto_optimization($data);
161 } catch (Exception $e) {
162 // Analytics tracking failed, continue
163 }
164
165 # Handle the POST request
166 return new WP_REST_Response(array(
167 'success' => true,
168 'message' => 'OTTO crawl notification received',
169 ), 200);
170 }
171
172 # NOTE: Cache system removed - these functions are no longer needed
173 # Kept for backward compatibility in case old cache directories need cleanup
174 function metasync_deleteDir($dir) {
175 if (!is_dir($dir)) {
176 return false;
177 }
178 $files = array_diff(scandir($dir), array('.', '..'));
179 foreach ($files as $file) {
180 $filePath = $dir . DIRECTORY_SEPARATOR . $file;
181 if (is_dir($filePath)) {
182 metasync_deleteDir($filePath);
183 } else {
184 unlink($filePath);
185 }
186 }
187 return rmdir($dir);
188 }
189
190 # Cleanup function for removing old cache directories (if they exist)
191 function metasync_invalidate_all_caches($folder = ''){
192 # Cache system removed - this function only exists to clean up old cache directories
193 if(!defined('WP_CONTENT_DIR')){
194 return false;
195 }
196 $wp_content_dir = WP_CONTENT_DIR;
197 $cache_dir = $wp_content_dir . '/metasync_caches';
198 if(in_array($folder, ['posts', 'pages'])){
199 $cache_dir = $cache_dir . '/' . $folder;
200 }
201 if(is_dir($cache_dir)){
202 metasync_deleteDir($cache_dir);
203 }
204 }
205
206 function metasync_start_otto(){
207
208 # PERFORMANCE FIX: Cache is now enabled for speed
209 # Skip initial cache cleanup to preserve existing cache
210 # Cache files are valuable for performance - only clear on OTTO updates
211 # Periodic cache clearing can be configured in plugin settings if needed
212
213 # exclude AJAX requests and WooCommerce transactional pages from OTTO SSR
214 # SSR is now ENABLED for: single products, product categories, product tags
215 # SSR is SKIPPED for: cart, checkout
216 # Note: title/description filters (pre_get_document_title, wp_head meta desc)
217 # run on ALL pages regardless — they are hooked unconditionally in seo-functions.php
218 if (
219 # disable ajax calls
220 isset($_GET['ucfrontajaxaction']) ||
221 # OTTO Preview mode - skip OTTO when previewing original content
222 (isset($_GET['otto_preview']) && $_GET['otto_preview'] === '1') ||
223 # WooCommerce shop archive page only (products and categories now use SSR)
224 //(function_exists('is_shop') && is_shop()) ||
225 # Cart page
226 (function_exists('is_cart') && is_cart()) ||
227 # Checkout page
228 (function_exists('is_checkout') && is_checkout()) ||
229 # My Account page
230 //(function_exists('is_account_page') && is_account_page()) ||
231 # Standard WordPress AJAX
232 (function_exists('wp_doing_ajax') && wp_doing_ajax()) ||
233 # check by constant
234 (defined('DOING_AJAX') && DOING_AJAX) ||
235 # WooCommerce AJAX endpoint (e.g., ?wc-ajax=update_cart)
236 (isset($_REQUEST['wc-ajax']) && !empty($_REQUEST['wc-ajax'])) ||
237 # AJAX requests via X-Requested-With header
238 (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') ||
239 # Gravity Forms submission detection - skip OTTO to allow form processing
240 (isset($_POST['gform_submit']) && (
241 is_array($_POST['gform_submit']) ||
242 (is_string($_POST['gform_submit']) && isset($_POST['is_submit_' . $_POST['gform_submit']]) && !empty($_POST['gform_submit']))
243 )) ||
244 # Gravity Forms AJAX submission
245 (isset($_POST['gform_ajax']) && isset($_POST['gform_submit']) && (
246 is_array($_POST['gform_submit']) || !empty($_POST['gform_submit'])
247 )) ||
248 # Gravity Forms file upload
249 (isset($_POST['gform_uploaded_files'])) ||
250 # Any Gravity Forms POST parameter
251 (isset($_POST['gform_submit']) || isset($_POST['gform_unique_id']) || isset($_POST['gform_field_values'])) ||
252 # Formidable Forms AJAX submission detection - skip OTTO to allow form processing
253 (isset($_POST['action']) && $_POST['action'] === 'frm_entries_create') ||
254 # Formidable Forms POST parameters
255 (isset($_POST['form_id']) && !empty($_POST['form_id'])) ||
256 # Formidable Forms action parameter
257 (isset($_POST['frm_action']) && !empty($_POST['frm_action'])) ||
258 # Formidable Forms item_key (used in form submissions)
259 (isset($_POST['item_key']) && !empty($_POST['item_key']))
260 ) {
261 return;
262 }
263
264 # fetch globals
265 global $metasync_options, $otto_enabled;
266
267 # OPTIMIZED: check for the disable otto for logged in users option using cached config
268 if(Metasync_Otto_Config::is_disabled_for_loggedin()){
269
270 # get user
271 $current_user = wp_get_current_user();
272
273 # check if user is logged in
274 if( !empty($current_user->ID)){
275
276 return;
277 }
278 }
279
280 # check if current URL is manually excluded from OTTO
281 # NOTE: auto-exclusions (false-positive 404 detections) are intentionally NOT checked
282 # here — they must not block OTTO rendering. Use metasync_is_otto_url_excluded() only
283 # in the webhook handler where we gate SEO meta writes.
284 $request_uri = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
285 $current_url = home_url(strtok($request_uri, '?') ?: $request_uri);
286 if (metasync_is_otto_url_manually_excluded($current_url)) {
287 return;
288 }
289
290 # BOT DETECTION: Always detect bots so crawl data reaches the SA backend.
291 # Real bots don't execute JS, so otto-tracker.js never fires for them.
292 # push_crawl_log_to_sa() fires a non-blocking wp_remote_post here instead.
293 $bot_detector = Metasync_Otto_Bot_Detector::get_instance();
294 $detection = $bot_detector->detect();
295 if ( $detection['is_bot'] ) {
296 $bot_detector->push_crawl_log_to_sa( $detection, $current_url );
297 }
298
299 # Optionally skip OTTO processing for bot traffic (when the setting is enabled)
300 if ($bot_detector->should_skip_otto()) {
301 // Log the bot locally and count the saved API call
302 $bot_detector->log_detection($detection);
303 $bot_stats_db = Metasync_Otto_Bot_Statistics_Database::get_instance();
304 $bot_stats_db->increment_api_calls_saved();
305
306 // Skip OTTO processing for this bot
307 return;
308 }
309
310 # OPTIMIZED: Check if Otto should be disabled for WP Rocket compatibility
311 if (class_exists('WP_Rocket')) {
312 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
313
314 if ($wp_rocket_compat_mode === 'disable_otto') {
315 return; # Exit early, Otto is disabled when WP Rocket is active
316 }
317 }
318
319 # Handle cache plugin compatibility early - before any caching happens
320 metasync_otto_handle_cache_compatibility();
321
322 # check if OTTO is disabled for this specific page/post
323 $post_id = get_the_ID();
324 if ($post_id && class_exists('Metasync_Otto_Frontend_Toolbar')) {
325 if (Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id)) {
326 return;
327 }
328 }
329
330 # check if we are having an otto request
331 if(!empty($_GET['is_otto_page_fetch'])){
332
333 # Block SEO plugins NOW for this internal fetch request
334 # metasync_otto_block_seo_plugins();
335 # $_SERVER['REQUEST_URI'] = remove_query_arg('is_otto_page_fetch', $_SERVER['REQUEST_URI']);
336 $block_title = !empty($_GET['otto_block_title']) && $_GET['otto_block_title'] === '1';
337 $block_description = !empty($_GET['otto_block_desc']) && $_GET['otto_block_desc'] === '1';
338
339 # Block SEO plugins conditionally based on what Otto has
340 if ($block_title || $block_description) {
341 metasync_otto_block_seo_plugins($block_title, $block_description);
342 }
343
344 # Remove ALL Otto parameters from REQUEST_URI to prevent them from appearing in pagination, etc.
345 $request_uri_raw = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
346 $_SERVER['REQUEST_URI'] = remove_query_arg(
347 ['is_otto_page_fetch', 'otto_block_title', 'otto_block_desc'],
348 $request_uri_raw
349 );
350
351 # Also remove from $_GET to prevent WordPress from using them
352 unset($_GET['is_otto_page_fetch']);
353 unset($_GET['otto_block_title']);
354 unset($_GET['otto_block_desc']);
355 return;
356 }
357
358 # to avoid unnecessary processese
359 # OPTIMIZED: check that otto is configured
360 # And the UUID is properly set before running OTT
361
362 # check that we have the option
363 if(!Metasync_Otto_Config::is_otto_enabled()){
364 return;
365 }
366
367 # check that otto is enabled
368 if(!$otto_enabled){
369 return;
370 }
371
372 # get the otto uuid
373 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
374
375 # start the class
376 $otto = new Metasync_otto_pixel($otto_uuid);
377
378 # call render
379 $otto->render_route_html();
380 }
381
382 /**
383 * Handle cache plugin compatibility with Otto
384 * Controls DONOTCACHEPAGE constant based on active plugins and configuration
385 * This function is called early in the WordPress lifecycle
386 */
387 function metasync_otto_handle_cache_compatibility() {
388 # Detect active plugins
389 $brizy_active = class_exists('Brizy_Editor') || defined('BRIZY_VERSION');
390 $wp_rocket_active = class_exists('WP_Rocket');
391 $sg_optimizer_active = is_plugin_active('sg-cachepress/sg-cachepress.php');
392
393 # OPTIMIZED: Get configuration option using cached config
394 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
395
396 # Check for Brizy posts in database
397 global $wpdb;
398 $has_brizy_posts = false;
399
400 if ($brizy_active) {
401 # OPTIMIZED: Check cache first (1-hour TTL) to avoid querying on every page load
402 $cached = get_transient('metasync_has_brizy_posts');
403 if ($cached !== false) {
404 $has_brizy_posts = ($cached === 'yes');
405 } else {
406 # Query database only if cache missed
407 $has_brizy_posts = $wpdb->get_var(
408 "SELECT COUNT(*) FROM {$wpdb->postmeta}
409 WHERE meta_key = 'brizy_post_uid'
410 AND meta_value != ''
411 LIMIT 1"
412 );
413
414 # Cache result for 1 hour
415 $result = !empty($has_brizy_posts) ? 'yes' : 'no';
416 set_transient('metasync_has_brizy_posts', $result, HOUR_IN_SECONDS);
417 }
418 }
419
420 # Determine if DONOTCACHEPAGE should be set
421 $should_set_donotcachepage = false;
422
423 # Case 1: Brizy is active with posts - always needed
424 if ($brizy_active && !empty($has_brizy_posts)) {
425 $should_set_donotcachepage = true;
426 }
427
428 # Case 2: SG Optimizer without WP Rocket - prevent conflicts
429 elseif ($sg_optimizer_active && !$wp_rocket_active) {
430 $should_set_donotcachepage = true;
431 }
432
433 # Case 3: User explicitly disabled Otto for WP Rocket compatibility
434 elseif ($wp_rocket_active && $wp_rocket_compat_mode === 'disable_otto') {
435 $should_set_donotcachepage = true;
436 return; # Exit early, Otto won't run
437 }
438
439 # Case 4: WP Rocket active with auto/buffer mode - DON'T set DONOTCACHEPAGE
440 # This allows WP Rocket optimizations to continue working
441
442 # Only set DONOTCACHEPAGE if needed
443 if ($should_set_donotcachepage && !defined('DONOTCACHEPAGE')) {
444 define('DONOTCACHEPAGE', true);
445 }
446
447 # Apply SG Optimizer specific filters only if SG is active and no WP Rocket
448 if ($sg_optimizer_active && !$wp_rocket_active && $should_set_donotcachepage) {
449 if (!defined('SG_CachePress_SUPERCACHER')) {
450 define('SG_CachePress_SUPERCACHER', false);
451 }
452
453 add_filter('sgo_html_cache_disable', '__return_true', 999);
454 add_filter('sgo_css_combine_exclude', '__return_true', 999);
455 add_filter('sgo_js_combine_exclude', '__return_true', 999);
456 add_filter('sgo_cache_this_page', '__return_false', 999);
457
458 if (!headers_sent()) {
459 header('Cache-Control: no-cache, must-revalidate, max-age=0');
460 header('X-Accel-Expires: 0');
461 }
462 }
463 }
464
465 /**
466 * Block SEO plugins conditionally based on what Otto is providing
467 * Only blocks title if Otto has title, only blocks description if Otto has description
468 * This prevents duplicate SEO tags while allowing fallback to SEO plugins when Otto has no data.
469 * Supports Yoast SEO, Rank Math, and AIOSEO (free + pro).
470 *
471 * @param bool $block_title Whether to block title tags.
472 * @param bool $block_description Whether to block description tags.
473 * @param array $description_tags Optional. Granular list of OTTO-provided description tags
474 * (e.g. ['meta[name=description]', 'meta[property=og:description]']).
475 * When provided, only matching AIOSEO tags are suppressed.
476 * When empty, all AIOSEO description tags are suppressed (legacy behavior).
477 */
478 function metasync_otto_block_seo_plugins($block_title = false, $block_description = false, $description_tags = []) {
479 # Disable Yoast SEO (free and premium)
480 if (is_plugin_active('wordpress-seo/wp-seo.php') ||
481 is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {
482
483 # TITLE: Never block Yoast's title output during SSR fetch.
484 # Yoast removes WordPress's native _wp_render_title_tag action and is the sole
485 # renderer of the <title> tag. Returning false/empty from wpseo_title or removing
486 # Title_Presenter leaves the page with NO <title> tag at all — OTTO's buffer
487 # post-processing then has nothing to replace, producing a missing title.
488 # Instead, let Yoast render its own title; OTTO's replace_title() will overwrite
489 # it in the final HTML buffer. deduplicate_title_tags() cleans up any duplicates.
490
491 # Block description only if Otto has description
492 if ($block_description) {
493 add_filter('wpseo_metadesc', '__return_false', 999);
494 add_filter('wpseo_meta_description', '__return_false', 999);
495 add_filter('wpseo_metakeywords', '__return_false', 999);
496 }
497
498 # Block Yoast's modern presenters — description only, never title
499 add_filter('wpseo_frontend_presenters', function($presenters) use ($block_description) {
500 if (!is_array($presenters)) return $presenters;
501
502 $presenters_to_remove = [];
503
504
505 # Remove description presenters only when OTTO has a description
506 if ($block_description) {
507 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Meta_Description_Presenter';
508 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Open_Graph\Description_Presenter';
509 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Twitter\Description_Presenter';
510 }
511
512 foreach ($presenters as $key => $presenter) {
513 // Safely get class name, suppressing autoload errors
514 // This prevents warnings when Composer autoloader tries to load deprecated Yoast files
515 $class_name = is_object($presenter) ? @get_class($presenter) : '';
516
517 if (!empty($class_name) && in_array($class_name, $presenters_to_remove)) {
518 unset($presenters[$key]);
519 }
520 }
521 return $presenters;
522 }, 999);
523 }
524
525 # Disable Rank Math
526 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
527 is_plugin_active('seo-by-rankmath/rank-math.php')) {
528
529 if ($block_title) {
530 add_filter('rank_math/frontend/title', '__return_empty_string', 999);
531 }
532
533 if ($block_description) {
534 add_filter('rank_math/frontend/description', '__return_false', 999);
535 add_filter('rank_math/frontend/show_keywords', '__return_false', 999);
536 }
537 }
538
539 # Disable AIOSEO (free and pro)
540 if (is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
541 is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
542
543 if ($block_title) {
544 add_filter('aioseo_title', '__return_empty_string', 999);
545 add_filter('aioseo_facebook_tags', function($meta) {
546 if (is_array($meta)) { unset($meta['og:title']); }
547 return $meta;
548 }, 999);
549 add_filter('aioseo_twitter_tags', function($meta) {
550 if (is_array($meta)) { unset($meta['twitter:title']); }
551 return $meta;
552 }, 999);
553 }
554
555 if ($block_description) {
556 # Use granular tag list when available to only block what OTTO provides
557 $tags = !empty($description_tags) ? $description_tags : [];
558 $block_standard = empty($tags) || in_array('meta[name=description]', $tags);
559 $block_og_desc = empty($tags) || in_array('meta[property=og:description]', $tags);
560 $block_tw_desc = empty($tags) || in_array('meta[name=twitter:description]', $tags);
561
562 if ($block_standard) {
563 add_filter('aioseo_description', '__return_empty_string', 999);
564 }
565 if ($block_og_desc) {
566 add_filter('aioseo_facebook_tags', function($meta) {
567 if (is_array($meta)) { unset($meta['og:description']); }
568 return $meta;
569 }, 999);
570 }
571 if ($block_tw_desc) {
572 add_filter('aioseo_twitter_tags', function($meta) {
573 if (is_array($meta)) { unset($meta['twitter:description']); }
574 return $meta;
575 }, 999);
576 }
577 }
578 }
579 }
580 # check that otto is not added via js to the site
581 function metasync_check_otto_js(){
582
583 # Cache result to avoid a blocking HTTP self-request (~2.4s)
584 # on every single admin page load.
585 $cache_key = 'metasync_otto_js_detected';
586 $cached = get_transient($cache_key);
587
588 if ($cached !== false) {
589 return $cached === 'yes';
590 }
591
592 # get the site url
593 $site_url = site_url() . '?is_otto_page_fetch=1';
594
595 # get the html — short timeout so admin doesn't hang
596 $page_data = wp_remote_get($site_url, array('timeout' => 5, 'sslverify' => false));
597
598 if (is_wp_error($page_data)) {
599 set_transient($cache_key, 'no', HOUR_IN_SECONDS);
600 return false;
601 }
602
603 # now get the html body
604 $body = wp_remote_retrieve_body($page_data);
605
606 # now load the body into html
607 $dom = new HtmlDocument($body);
608
609 # now check the dom for a meta tag with
610 $script = $dom->find('script#sa-dynamic-optimization', 0);
611
612 # check script
613 if($script AND !empty($script->getAttribute('data-uuid'))){
614 set_transient($cache_key, 'yes', 12 * HOUR_IN_SECONDS);
615 return true;
616 }
617
618 set_transient($cache_key, 'no', 12 * HOUR_IN_SECONDS);
619 return false;
620 };
621
622 # Handle AJAX Clear Cache request
623 # NOTE: Cache system removed - this is now a no-op
624 function metasync_clear_otto_cache_handler() {
625 if (!empty($_GET['clear_otto_cache'])) {
626 # Cache system has been removed - no cache to clear
627 wp_send_json_success(['message' => 'Cache system removed - all pages processed in real-time']);
628 }
629 else {
630 wp_send_json_error(['message' => 'Missing parameter']);
631 }
632 }
633
634 # Clear cache hook
635 add_action('wp_ajax_metasync_clear_otto_cache', 'metasync_clear_otto_cache_handler');
636
637 # add admin action to check script
638 function metasync_show_otto_ssr_notice() {
639 if (!Metasync::current_user_has_plugin_access()) {
640 return; // Only show to admins
641 }
642
643 # Get the plugin name using centralized method
644 $plugin_name = Metasync::get_effective_plugin_name();
645 $whitelabel_otto_name = Metasync::get_whitelabel_otto_name();
646 if (metasync_check_otto_js()) {
647
648 # Show admin notice with plugin name included in the message
649 echo '<div class="notice notice-error">
650 <p><b>Warning from ' . esc_html($plugin_name) . '</b>
651 <br>
652 ' . 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
653 </p>
654 </div>';
655 }
656 }
657
658 add_action('admin_notices', 'metasync_show_otto_ssr_notice');
659
660 # staging dummy change
661 # load otto in the wp hook
662 add_action('wp', 'metasync_start_otto');
663
664
665 # ENHANCED OTTO SEO INTEGRATION
666 # Register async SEO processing hook
667 add_action('metasync_process_seo_job', 'metasync_process_otto_seo_data');
668
669 # Process OTTO SEO data and update WordPress meta fields for SEO plugins
670 # This function now runs asynchronously via WordPress cron system
671
672 function metasync_process_otto_seo_data($route) {
673 try {
674 # Validate input
675 if (empty($route) || !is_string($route)) {
676 return false;
677 }
678
679 # CPU load check — defer if server is under load
680 if (!Metasync_CPU_Monitor::is_load_safe()) {
681 wp_schedule_single_event(time() + 60, 'metasync_process_seo_job', array($route));
682 return false;
683 }
684
685 # Resolve redirect table: use final destination URL before 404 checks and OTTO processing
686 $route = metasync_otto_resolve_redirect_to_final_url($route);
687
688 # Skip excluded URLs - don't process SEO data for them
689 if (metasync_is_otto_url_excluded($route)) {
690 //error_log('MetaSync OTTO: Skipping SEO processing for excluded URL: ' . $route);
691 return false;
692 }
693
694 # Pre-flight 404 check: exclude URLs that would return 404 before making API call
695 if (!metasync_otto_is_url_available($route)) {
696 error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route}");
697 metasync_otto_auto_exclude_404_url($route);
698 return false;
699 }
700
701 # Get OTTO UUID from settings
702 # OPTIMIZED: Use cached options
703 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
704
705 if (empty($otto_uuid)) {
706 return false;
707 }
708
709 # Meta descriptions are always enabled by default - no check needed
710
711 # Fetch SEO data from OTTO API
712 $seo_data = metasync_fetch_otto_seo_data($route, $otto_uuid);
713
714 if (!$seo_data) {
715 metasync_record_failed_action( 'metasync_process_seo_job' );
716 return false;
717 }
718
719 # Mark this URL as crawled by OTTO for SSR
720 # Extract domain and path from route
721 $parsed_url = parse_url($route);
722 $domain_with_scheme = ($parsed_url['scheme'] ?? 'https') . '://' . ($parsed_url['host'] ?? '');
723 $url_path = ($parsed_url['path'] ?? '/');
724
725 # Create crawl data structure
726 $crawl_data = array(
727 'domain' => $domain_with_scheme,
728 'urls' => array($url_path)
729 );
730
731 # Load Otto pixel class and save crawl data
732 $otto_pixel = new Metasync_otto_pixel($otto_uuid);
733 $otto_pixel->save_crawl_data($crawl_data);
734
735 # Get WordPress post ID from URL
736 $post_id = url_to_postid($route);
737
738 # Special handling for WooCommerce shop page (url_to_postid doesn't work for it)
739 if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) {
740 # Check if this URL is the WooCommerce shop page
741 $shop_page_id = wc_get_page_id('shop');
742 if ($shop_page_id > 0) {
743 $shop_url = get_permalink($shop_page_id);
744 $route_normalized = rtrim($route, '/');
745 $shop_url_normalized = rtrim($shop_url, '/');
746
747 if ($route_normalized === $shop_url_normalized) {
748 $post_id = $shop_page_id;
749 }
750 }
751 }
752
753 # Try to find WooCommerce product by URL if url_to_postid failed
754 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
755 # Extract product slug from URL
756 $product_slug = basename(parse_url($route, PHP_URL_PATH));
757
758 # Try to get product by slug
759 $products = wc_get_products(array(
760 'name' => $product_slug,
761 'limit' => 1,
762 'status' => 'publish',
763 ));
764
765 if (empty($products)) {
766 # Fallback: try by slug using WP_Query
767 $args = array(
768 'post_type' => 'product',
769 'name' => $product_slug,
770 'posts_per_page' => 1,
771 'post_status' => 'publish',
772 );
773 $query = new WP_Query($args);
774
775 if ($query->have_posts()) {
776 $product_post = $query->posts[0];
777 $post_id = $product_post->ID;
778 }
779 } else {
780 $product = $products[0];
781 $post_id = $product->get_id();
782 }
783 }
784
785 if (!$post_id || $post_id <= 0) {
786 # Check if this is a category page
787 if (strpos($route, '/category/') !== false) {
788 # Extract category slug from URL
789 $category_slug = basename(parse_url($route, PHP_URL_PATH));
790 $category = get_category_by_slug($category_slug);
791
792 if ($category) {
793 # Check if category would return 404 before applying OTTO changes
794 if (metasync_would_term_return_404($category->term_id, 'category', $route)) {
795 error_log("MetaSync OTTO: Skipping SEO processing for category that would return 404: {$route} (Category ID: {$category->term_id})");
796 metasync_otto_auto_exclude_404_url($route);
797 return false;
798 }
799
800 # Update comprehensive category SEO meta fields
801 $update_result = metasync_update_comprehensive_category_seo_fields($category->term_id, $seo_data);
802
803 if ($update_result['updated']) {
804 # Prepare trimmed values to 30 characters
805 $trim = function($value) {
806 if ($value === null) { return ''; }
807 $value = (string) $value;
808 $value = trim($value);
809 if (mb_strlen($value) > 30) {
810 return mb_substr($value, 0, 30);
811 }
812 return $value;
813 };
814
815 # Log individual field updates for category
816 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
817 $short = '';
818 $title = '';
819
820 switch ($field_type) {
821 case 'meta_title':
822 $short = $trim($field_value);
823 $title = "Category Meta Title Update ({$short}...)";
824 break;
825 case 'meta_description':
826 $short = $trim($field_value);
827 $title = "Category Meta Description Update ({$short}...)";
828 break;
829 case 'meta_keywords':
830 $short = $trim($field_value);
831 $title = "Category Meta Keywords Update ({$short}...)";
832 break;
833 case 'og_title':
834 $short = $trim($field_value);
835 $title = "Category Open Graph Title Update ({$short}...)";
836 break;
837 case 'og_description':
838 $short = $trim($field_value);
839 $title = "Category Open Graph Description Update ({$short}...)";
840 break;
841 case 'twitter_title':
842 $short = $trim($field_value);
843 $title = "Category Twitter Title Update ({$short}...)";
844 break;
845 case 'twitter_description':
846 $short = $trim($field_value);
847 $title = "Category Twitter Description Update ({$short}...)";
848 break;
849 case 'image_alt_data':
850 $image_count = count($field_value);
851 $title = "Category Image Alt Text Update ({$image_count} images)";
852 break;
853 case 'headings_data':
854 $heading_count = count($field_value);
855 $title = "Category Headings Update ({$heading_count} headings)";
856 break;
857 case 'structured_data':
858 $title = 'Category Structured Data Update';
859 break;
860 }
861
862 if (!empty($title)) {
863 metasync_log_sync_history([
864 'title' => $title,
865 'source' => 'OTTO SEO',
866 'status' => 'published',
867 'content_type' => 'Category SEO',
868 'url' => $route,
869 'meta_data' => json_encode([
870 'field' => $field_type,
871 'field_value' => $field_value,
872 'category_id' => $category->term_id,
873 'category_name' => $category->name
874 ])
875 ]);
876 }
877 }
878
879 return true;
880 }
881
882 return false;
883 }
884 }
885
886 # Check if this is a WooCommerce product category
887 if (strpos($route, '/product-category/') !== false) {
888 # Extract product category slug from URL
889 $category_slug = basename(parse_url($route, PHP_URL_PATH));
890 $term = get_term_by('slug', $category_slug, 'product_cat');
891
892 if ($term && !is_wp_error($term)) {
893 # Check if product category would return 404 before applying OTTO changes
894 if (metasync_would_term_return_404($term->term_id, 'product_cat', $route)) {
895 error_log("MetaSync OTTO: Skipping SEO processing for product category that would return 404: {$route} (Term ID: {$term->term_id})");
896 metasync_otto_auto_exclude_404_url($route);
897 return false;
898 }
899
900 # Update comprehensive taxonomy SEO meta fields
901 $update_result = metasync_update_comprehensive_taxonomy_seo_fields($term->term_id, 'product_cat', $seo_data);
902
903 if ($update_result['updated']) {
904 # Prepare trimmed values to 30 characters
905 $trim = function($value) {
906 if ($value === null) { return ''; }
907 $value = (string) $value;
908 $value = trim($value);
909 if (mb_strlen($value) > 30) {
910 return mb_substr($value, 0, 30);
911 }
912 return $value;
913 };
914
915 # Log individual field updates for product category
916 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
917 $short = '';
918 $title = '';
919
920 switch ($field_type) {
921 case 'meta_title':
922 $short = $trim($field_value);
923 $title = "Product Category Meta Title Update ({$short}...)";
924 break;
925 case 'meta_description':
926 $short = $trim($field_value);
927 $title = "Product Category Meta Description Update ({$short}...)";
928 break;
929 case 'meta_keywords':
930 $short = $trim($field_value);
931 $title = "Product Category Meta Keywords Update ({$short}...)";
932 break;
933 case 'og_title':
934 $short = $trim($field_value);
935 $title = "Product Category Open Graph Title Update ({$short}...)";
936 break;
937 case 'og_description':
938 $short = $trim($field_value);
939 $title = "Product Category Open Graph Description Update ({$short}...)";
940 break;
941 case 'twitter_title':
942 $short = $trim($field_value);
943 $title = "Product Category Twitter Title Update ({$short}...)";
944 break;
945 case 'twitter_description':
946 $short = $trim($field_value);
947 $title = "Product Category Twitter Description Update ({$short}...)";
948 break;
949 case 'image_alt_data':
950 $image_count = count($field_value);
951 $title = "Product Category Image Alt Text Update ({$image_count} images)";
952 break;
953 case 'headings_data':
954 $heading_count = count($field_value);
955 $title = "Product Category Headings Update ({$heading_count} headings)";
956 break;
957 case 'structured_data':
958 $title = 'Product Category Structured Data Update';
959 break;
960 }
961
962 if (!empty($title)) {
963 metasync_log_sync_history([
964 'title' => $title,
965 'source' => 'OTTO SEO',
966 'status' => 'published',
967 'content_type' => 'WooCommerce Product Category SEO',
968 'url' => $route,
969 'meta_data' => json_encode([
970 'field' => $field_type,
971 'field_value' => $field_value,
972 'term_id' => $term->term_id,
973 'term_name' => $term->name,
974 'taxonomy' => 'product_cat'
975 ])
976 ]);
977 }
978 }
979
980 return true;
981 }
982
983 return false;
984 }
985 }
986
987 # Check if this is the home page (landing page)
988 $site_url = rtrim(site_url(), '/');
989 $route_clean = rtrim($route, '/');
990
991 if ($route_clean === $site_url) {
992 # Get the home page (front page)
993 $front_page_id = get_option('page_on_front');
994 $home_page = null;
995
996 if ($front_page_id && $front_page_id > 0) {
997 $home_page = get_post($front_page_id);
998 } else {
999 # If no static front page is set, get the latest post
1000 $home_page = get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null;
1001 }
1002
1003 if ($home_page) {
1004 # Check if home page would return 404 before applying OTTO changes
1005 if (metasync_would_page_return_404($home_page->ID, $route)) {
1006 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})");
1007 metasync_otto_auto_exclude_404_url($route);
1008 return false;
1009 }
1010
1011 # Update comprehensive home page SEO meta fields
1012 $update_result = metasync_update_comprehensive_seo_fields($home_page->ID, $seo_data);
1013
1014 if ($update_result['updated']) {
1015 # Clear relevant caches
1016 metasync_clear_post_seo_caches($home_page->ID);
1017
1018 # Prepare trimmed values to 30 characters
1019 $trim = function($value) {
1020 if ($value === null) { return ''; }
1021 $value = (string) $value;
1022 $value = trim($value);
1023 if (mb_strlen($value) > 30) {
1024 return mb_substr($value, 0, 30);
1025 }
1026 return $value;
1027 };
1028
1029 # Log individual field updates for home page
1030 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1031 $short = '';
1032 $title = '';
1033
1034 switch ($field_type) {
1035 case 'meta_title':
1036 $short = $trim($field_value);
1037 $title = "Home Page Meta Title Update ({$short}...)";
1038 break;
1039 case 'meta_description':
1040 $short = $trim($field_value);
1041 $title = "Home Page Meta Description Update ({$short}...)";
1042 break;
1043 case 'meta_keywords':
1044 $short = $trim($field_value);
1045 $title = "Home Page Meta Keywords Update ({$short}...)";
1046 break;
1047 case 'og_title':
1048 $short = $trim($field_value);
1049 $title = "Home Page Open Graph Title Update ({$short}...)";
1050 break;
1051 case 'og_description':
1052 $short = $trim($field_value);
1053 $title = "Home Page Open Graph Description Update ({$short}...)";
1054 break;
1055 case 'twitter_title':
1056 $short = $trim($field_value);
1057 $title = "Home Page Twitter Title Update ({$short}...)";
1058 break;
1059 case 'twitter_description':
1060 $short = $trim($field_value);
1061 $title = "Home Page Twitter Description Update ({$short}...)";
1062 break;
1063 case 'image_alt_data':
1064 $image_count = count($field_value);
1065 $title = "Home Page Image Alt Text Update ({$image_count} images)";
1066 break;
1067 case 'headings_data':
1068 $heading_count = count($field_value);
1069 $title = "Home Page Headings Update ({$heading_count} headings)";
1070 break;
1071 case 'structured_data':
1072 $title = 'Home Page Structured Data Update';
1073 break;
1074 }
1075
1076 if (!empty($title)) {
1077 metasync_log_sync_history([
1078 'title' => $title,
1079 'source' => 'OTTO SEO',
1080 'status' => 'published',
1081 'content_type' => 'Home Page SEO',
1082 'url' => $route,
1083 'meta_data' => json_encode([
1084 'field' => $field_type,
1085 'field_value' => $field_value,
1086 'post_id' => $home_page->ID
1087 ])
1088 ]);
1089 }
1090 }
1091
1092 return true;
1093 }
1094
1095 return false;
1096 }
1097 }
1098
1099 # Check if this is the blog/posts page (page_for_posts)
1100 $posts_page_id = intval(get_option('page_for_posts'));
1101 if ($posts_page_id > 0) {
1102 $posts_page = get_post($posts_page_id);
1103 $posts_page_url = rtrim(get_permalink($posts_page_id), '/');
1104
1105 if ($posts_page && $route_clean === $posts_page_url) {
1106 # Check if blog page would return 404
1107 if (metasync_would_page_return_404($posts_page->ID, $route)) {
1108 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})");
1109 metasync_otto_auto_exclude_404_url($route);
1110 return false;
1111 }
1112
1113 # Update comprehensive blog page SEO meta fields
1114 $update_result = metasync_update_comprehensive_seo_fields($posts_page->ID, $seo_data);
1115
1116 if ($update_result['updated']) {
1117 # Clear relevant caches
1118 metasync_clear_post_seo_caches($posts_page->ID);
1119
1120 # Prepare trimmed values to 30 characters
1121 $trim = function($value) {
1122 if ($value === null) { return ''; }
1123 $value = (string) $value;
1124 $value = trim($value);
1125 if (mb_strlen($value) > 30) {
1126 return mb_substr($value, 0, 30);
1127 }
1128 return $value;
1129 };
1130
1131 # Log individual field updates for blog page
1132 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1133 $short = '';
1134 $title = '';
1135
1136 switch ($field_type) {
1137 case 'meta_title':
1138 $short = $trim($field_value);
1139 $title = "Blog Page Meta Title Update ({$short}...)";
1140 break;
1141 case 'meta_description':
1142 $short = $trim($field_value);
1143 $title = "Blog Page Meta Description Update ({$short}...)";
1144 break;
1145 case 'meta_keywords':
1146 $short = $trim($field_value);
1147 $title = "Blog Page Meta Keywords Update ({$short}...)";
1148 break;
1149 case 'og_title':
1150 $short = $trim($field_value);
1151 $title = "Blog Page Open Graph Title Update ({$short}...)";
1152 break;
1153 case 'og_description':
1154 $short = $trim($field_value);
1155 $title = "Blog Page Open Graph Description Update ({$short}...)";
1156 break;
1157 case 'twitter_title':
1158 $short = $trim($field_value);
1159 $title = "Blog Page Twitter Title Update ({$short}...)";
1160 break;
1161 case 'twitter_description':
1162 $short = $trim($field_value);
1163 $title = "Blog Page Twitter Description Update ({$short}...)";
1164 break;
1165 case 'image_alt_data':
1166 $image_count = count($field_value);
1167 $title = "Blog Page Image Alt Text Update ({$image_count} images)";
1168 break;
1169 case 'headings_data':
1170 $heading_count = count($field_value);
1171 $title = "Blog Page Headings Update ({$heading_count} headings)";
1172 break;
1173 case 'structured_data':
1174 $title = 'Blog Page Structured Data Update';
1175 break;
1176 }
1177
1178 if (!empty($title)) {
1179 metasync_log_sync_history([
1180 'title' => $title,
1181 'source' => 'OTTO SEO',
1182 'status' => 'published',
1183 'content_type' => 'Blog Page SEO',
1184 'url' => $route,
1185 'meta_data' => json_encode([
1186 'field' => $field_type,
1187 'field_value' => $field_value,
1188 'post_id' => $posts_page->ID
1189 ])
1190 ]);
1191 }
1192 }
1193
1194 return true;
1195 }
1196
1197 return false;
1198 }
1199 }
1200
1201 # URL didn't resolve to any supported entity (post, category, home page, blog page)
1202 # Treat as 404 and auto-exclude (e.g. deleted post, non-existent page)
1203 if (!metasync_otto_is_url_available($route)) {
1204 error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404 (no matching entity): {$route}");
1205 metasync_otto_auto_exclude_404_url($route);
1206 }
1207 return false;
1208 }
1209
1210 # Verify this is actually a post, page, or WooCommerce product
1211 $post = get_post($post_id);
1212
1213 # Get supported post types dynamically
1214 $supported_post_types = metasync_get_supported_post_types();
1215
1216 if (!$post || !in_array($post->post_type, $supported_post_types)) {
1217 # Skip unsupported post types
1218 return false;
1219 }
1220
1221 # Check if page would return 404 before applying OTTO changes
1222 if (metasync_would_page_return_404($post_id, $route)) {
1223 error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route} (Post ID: {$post_id}, Status: {$post->post_status})");
1224 metasync_otto_auto_exclude_404_url($route);
1225 return false;
1226 }
1227
1228 # Update comprehensive SEO meta fields
1229 $update_result = metasync_update_comprehensive_seo_fields($post_id, $seo_data);
1230
1231 if ($update_result['updated']) {
1232 # Clear relevant caches
1233 metasync_clear_post_seo_caches($post_id);
1234
1235 # Prepare trimmed values to 30 characters
1236 $trim = function($value) {
1237 if ($value === null) { return ''; }
1238 $value = (string) $value;
1239 $value = trim($value);
1240 if (mb_strlen($value) > 30) {
1241 return mb_substr($value, 0, 30);
1242 }
1243 return $value;
1244 };
1245
1246 # Log individual field updates
1247 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1248 $short = '';
1249 $title = '';
1250
1251 switch ($field_type) {
1252 case 'meta_title':
1253 $short = $trim($field_value);
1254 $title = 'Meta Title Update (' . $short . '...)';
1255 break;
1256 case 'meta_description':
1257 $short = $trim($field_value);
1258 $title = 'Meta Description Update (' . $short . '...)';
1259 break;
1260 case 'meta_keywords':
1261 $short = $trim($field_value);
1262 $title = 'Meta Keywords Update (' . $short . '...)';
1263 break;
1264 case 'og_title':
1265 $short = $trim($field_value);
1266 $title = 'Open Graph Title Update (' . $short . '...)';
1267 break;
1268 case 'og_description':
1269 $short = $trim($field_value);
1270 $title = 'Open Graph Description Update (' . $short . '...)';
1271 break;
1272 case 'twitter_title':
1273 $short = $trim($field_value);
1274 $title = 'Twitter Title Update (' . $short . '...)';
1275 break;
1276 case 'twitter_description':
1277 $short = $trim($field_value);
1278 $title = 'Twitter Description Update (' . $short . '...)';
1279 break;
1280 case 'image_alt_data':
1281 $image_count = count($field_value);
1282 $title = "Image Alt Text Update ({$image_count} images)";
1283 break;
1284 case 'headings_data':
1285 $heading_count = count($field_value);
1286 $title = "Headings Update ({$heading_count} headings)";
1287 break;
1288 case 'structured_data':
1289 $title = 'Structured Data Update';
1290 break;
1291 }
1292
1293 if (!empty($title)) {
1294 metasync_log_sync_history([
1295 'title' => $title,
1296 'source' => 'OTTO SEO',
1297 'status' => 'published',
1298 'content_type' => 'SEO Meta',
1299 'url' => $route,
1300 'meta_data' => json_encode([
1301 'field' => $field_type,
1302 'field_value' => $field_value,
1303 'post_id' => $post_id
1304 ])
1305 ]);
1306 }
1307 }
1308
1309 return true;
1310 }
1311
1312 return false;
1313
1314 } catch (Exception $e) {
1315 metasync_record_failed_action( 'metasync_process_seo_job' );
1316 return false;
1317 }
1318 }
1319
1320 /**
1321 * Log sync history entry
1322 * @param array $data Sync data to log
1323 */
1324 function metasync_log_sync_history($data) {
1325 try {
1326 // Classes are now autoloaded, no need for manual require
1327 $sync_db = new Metasync_Sync_History_Database();
1328
1329 // Minimal duplicate prevention within short time window
1330 if (!empty($data['title']) && !empty($data['source'])) {
1331 global $wpdb;
1332 $table = $wpdb->prefix . Metasync_Sync_History_Database::$table_name;
1333 $recent = $wpdb->get_var($wpdb->prepare(
1334 "SELECT COUNT(*) FROM `$table` WHERE title = %s AND source = %s AND created_at >= %s",
1335 $data['title'],
1336 $data['source'],
1337 gmdate('Y-m-d H:i:s', time() - 60)
1338 ));
1339 if ((int)$recent > 0) {
1340 return; // skip duplicate log within 60 seconds
1341 }
1342 }
1343
1344 $sync_db->add($data);
1345
1346 } catch (Exception $e) {
1347 error_log("MetaSync: Failed to log sync history: " . $e->getMessage());
1348 }
1349 }
1350
1351 /**
1352 * Get supported post types for OTTO SEO optimization
1353 * Includes WooCommerce products if WooCommerce is active
1354 *
1355 * @return array List of supported post types
1356 */
1357 function metasync_get_supported_post_types() {
1358 # Start with default post types
1359 $post_types = ['post', 'page'];
1360
1361 # Add WooCommerce product post type if WooCommerce is active
1362 if (class_exists('WooCommerce') || function_exists('is_woocommerce')) {
1363 $post_types[] = 'product';
1364 }
1365
1366 # Include all public custom post types (e.g. 'location', 'service', 'team', etc.)
1367 # so OTTO can write post meta for them during metasync_process_otto_seo_data().
1368 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
1369 if (!empty($custom_post_types)) {
1370 $post_types = array_merge($post_types, array_values($custom_post_types));
1371 }
1372
1373 # Allow developers to filter supported post types
1374 $post_types = apply_filters('metasync_otto_supported_post_types', $post_types);
1375
1376 return $post_types;
1377 }
1378
1379 /**
1380 * Get supported taxonomies for OTTO SEO optimization
1381 * Includes WooCommerce product categories and tags if WooCommerce is active
1382 *
1383 * @return array List of supported taxonomies
1384 */
1385 function metasync_get_supported_taxonomies() {
1386 # Start with default taxonomies
1387 $taxonomies = ['category'];
1388
1389 # Add WooCommerce taxonomies if WooCommerce is active
1390 if (class_exists('WooCommerce') || function_exists('is_woocommerce')) {
1391 $taxonomies[] = 'product_cat'; # WooCommerce product categories
1392 $taxonomies[] = 'product_tag'; # WooCommerce product tags
1393 }
1394
1395 # Allow developers to filter supported taxonomies
1396 $taxonomies = apply_filters('metasync_otto_supported_taxonomies', $taxonomies);
1397
1398 return $taxonomies;
1399 }
1400
1401 /**
1402 * Resolve a URL through the Redirect Manager table to its final destination (follows redirect chains).
1403 * Used before 404 checks and OTTO processing so the final canonical URL is used, not intermediate redirects.
1404 *
1405 * @param string $url Full URL (e.g. https://example.com/old-page)
1406 * @return string Final destination URL, or original $url if no redirect matches
1407 */
1408 function metasync_otto_resolve_redirect_to_final_url($url)
1409 {
1410 if (empty($url) || !is_string($url)) {
1411 return $url;
1412 }
1413 try {
1414 $db_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection-database.php';
1415 $class_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection.php';
1416 if (!file_exists($db_path) || !file_exists($class_path)) {
1417 return $url;
1418 }
1419 require_once $db_path;
1420 require_once $class_path;
1421 $db = new Metasync_Redirection_Database();
1422 $redirect = new Metasync_Redirection($db);
1423 return $redirect->resolve_url_to_final_destination($url, 10);
1424 } catch (Exception $e) {
1425 error_log('MetaSync OTTO: Redirect resolution failed for ' . $url . ' - ' . $e->getMessage());
1426 return $url;
1427 }
1428 }
1429
1430 /**
1431 * Auto-exclude a URL from OTTO with description "Auto-excluded: 404"
1432 * Called when a URL is detected as returning 404 so it won't be sent to OTTO again
1433 *
1434 * @param string $url Full URL to exclude (e.g. https://example.com/404-page)
1435 * @return bool|string True on success, false on failure, 'duplicate'/'reactivated' if already exists
1436 */
1437 function metasync_otto_auto_exclude_404_url($url)
1438 {
1439 if (empty($url) || !is_string($url)) {
1440 return false;
1441 }
1442 try {
1443 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1444 $db = new Metasync_Otto_Excluded_URLs_Database();
1445 return $db->add([
1446 'url_pattern' => $url,
1447 'pattern_type' => 'exact',
1448 'description' => 'Auto-excluded: 404',
1449 'status' => 'active',
1450 'auto_excluded' => 1,
1451 ]);
1452 } catch (Exception $e) {
1453 error_log('MetaSync OTTO: Failed to auto-exclude 404 URL: ' . $url . ' - ' . $e->getMessage());
1454 return false;
1455 }
1456 }
1457
1458 /**
1459 * Remove a URL from the OTTO auto-exclusion list.
1460 * Called when OTTO sends a webhook for a URL, confirming it is valid and crawlable.
1461 * Only removes records where auto_excluded = 1 (never removes manual exclusions).
1462 *
1463 * @param string $url Full URL to un-exclude (e.g. https://example.com/location/page)
1464 * @return bool True on success
1465 */
1466 function metasync_otto_remove_auto_exclusion($url)
1467 {
1468 if (empty($url) || !is_string($url)) {
1469 return false;
1470 }
1471 try {
1472 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1473 $db = new Metasync_Otto_Excluded_URLs_Database();
1474 global $wpdb;
1475 $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
1476 # Normalize the same way is_url_excluded() does
1477 $url_normalized = rtrim(trim($url), '/');
1478 $records = $wpdb->get_results(
1479 $wpdb->prepare(
1480 "SELECT id FROM `{$table}` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'",
1481 $url_normalized
1482 )
1483 );
1484 if (!empty($records)) {
1485 $ids = array_map(function ($r) { return (int) $r->id; }, $records);
1486 $db->delete($ids);
1487 }
1488 return true;
1489 } catch (Exception $e) {
1490 return false;
1491 }
1492 }
1493
1494 /**
1495 * Check if a URL is MANUALLY excluded from OTTO (auto_excluded = 0).
1496 * Used at render time (metasync_start_otto) — auto-exclusions must NOT block
1497 * rendering because they are often false positives (e.g. custom post types that
1498 * url_to_postid() can't resolve). Auto-exclusions are only used to gate the
1499 * SEO meta-writing webhook path.
1500 *
1501 * @param string $url URL to check
1502 * @return bool True if URL has a manual exclusion
1503 */
1504 function metasync_is_otto_url_manually_excluded($url)
1505 {
1506 if (empty($url) || !is_string($url)) {
1507 return false;
1508 }
1509 try {
1510 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1511 global $wpdb;
1512 $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
1513 $url_normalized = rtrim(trim($url), '/');
1514
1515 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- no user input, table name from $wpdb->prefix
1516 $records = $wpdb->get_results(
1517 "SELECT url_pattern, pattern_type FROM `{$table}` WHERE status = 'active' AND (auto_excluded = 0 OR auto_excluded IS NULL) ORDER BY created_at DESC"
1518 );
1519
1520 // Graceful recovery: auto_excluded column missing on pre-v2.7.4 installs.
1521 // Run ALTER TABLE to add it and treat URL as not excluded so OTTO continues rendering.
1522 if ($wpdb->last_error && strpos($wpdb->last_error, 'auto_excluded') !== false) {
1523 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1524 $wpdb->query("ALTER TABLE `{$table}` ADD COLUMN `auto_excluded` TINYINT(1) NOT NULL DEFAULT 0");
1525 return false;
1526 }
1527
1528 if (empty($records)) {
1529 return false;
1530 }
1531
1532 foreach ($records as $excluded) {
1533 $pattern = rtrim(trim($excluded->url_pattern), '/');
1534 $pattern_type = $excluded->pattern_type;
1535
1536 switch ($pattern_type) {
1537 case 'exact':
1538 if ($url_normalized === $pattern) {
1539 return true;
1540 }
1541 break;
1542 case 'contain':
1543 if (strpos($url_normalized, $pattern) !== false) {
1544 return true;
1545 }
1546 break;
1547 case 'start':
1548 if (strpos($url_normalized, $pattern) === 0) {
1549 return true;
1550 }
1551 break;
1552 }
1553 }
1554 return false;
1555 } catch (Exception $e) {
1556 return false;
1557 }
1558 }
1559
1560 /**
1561 * Check if a URL is excluded from OTTO
1562 * @param string $url URL to check
1563 * @return bool True if URL is excluded, false otherwise
1564 */
1565 function metasync_is_otto_url_excluded($url)
1566 {
1567 try {
1568 // Load database class
1569 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1570 $db = new Metasync_Otto_Excluded_URLs_Database();
1571
1572 // Check if URL is excluded
1573 return $db->is_url_excluded($url);
1574
1575 } catch (Exception $e) {
1576 return false;
1577 }
1578 }
1579
1580 /**
1581 * Check if a URL is now available (would NOT return 404)
1582 * Uses same resolution logic as metasync_process_otto_seo_data
1583 * Used when rechecking auto-excluded 404 URLs after 7 days
1584 *
1585 * @param string $url Full URL to check (e.g. https://example.com/page)
1586 * @return bool True if URL is accessible, false if it would return 404
1587 */
1588 function metasync_otto_is_url_available($url)
1589 {
1590 if (empty($url) || !is_string($url)) {
1591 return false;
1592 }
1593
1594 $route = metasync_otto_resolve_redirect_to_final_url($url);
1595 $post_id = url_to_postid($route);
1596
1597 # WooCommerce shop page
1598 if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) {
1599 $shop_page_id = wc_get_page_id('shop');
1600 if ($shop_page_id > 0) {
1601 $shop_url = get_permalink($shop_page_id);
1602 if (rtrim($route, '/') === rtrim($shop_url, '/')) {
1603 $post_id = $shop_page_id;
1604 }
1605 }
1606 }
1607
1608 # WooCommerce product by slug
1609 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
1610 $product_slug = basename(parse_url($route, PHP_URL_PATH));
1611 $products = wc_get_products(array('name' => $product_slug, 'limit' => 1, 'status' => 'publish'));
1612 if (!empty($products)) {
1613 $post_id = $products[0]->get_id();
1614 } else {
1615 $query = new WP_Query(array(
1616 'post_type' => 'product',
1617 'name' => $product_slug,
1618 'posts_per_page' => 1,
1619 'post_status' => 'publish',
1620 ));
1621 if ($query->have_posts()) {
1622 $post_id = $query->posts[0]->ID;
1623 }
1624 }
1625 }
1626
1627 if ($post_id && $post_id > 0) {
1628 $post = get_post($post_id);
1629 # Accept any post type — custom post types (e.g. 'location', 'service') are valid URLs.
1630 # The old in_array check against metasync_get_supported_post_types() caused CPT URLs
1631 # to be wrongly auto-excluded as "404" pages.
1632 if ($post) {
1633 return !metasync_would_page_return_404($post_id, $route);
1634 }
1635 }
1636
1637 # Category
1638 if (strpos($route, '/category/') !== false) {
1639 $category_slug = basename(parse_url($route, PHP_URL_PATH));
1640 $category = get_category_by_slug($category_slug);
1641 if ($category) {
1642 return !metasync_would_term_return_404($category->term_id, 'category', $route);
1643 }
1644 }
1645
1646 # WooCommerce product category
1647 if (strpos($route, '/product-category/') !== false) {
1648 $category_slug = basename(parse_url($route, PHP_URL_PATH));
1649 $term = get_term_by('slug', $category_slug, 'product_cat');
1650 if ($term && !is_wp_error($term)) {
1651 return !metasync_would_term_return_404($term->term_id, 'product_cat', $route);
1652 }
1653 }
1654
1655 # Home page
1656 if (rtrim($route, '/') === rtrim(site_url(), '/')) {
1657 $front_page_id = get_option('page_on_front');
1658 $home_page = ($front_page_id && $front_page_id > 0)
1659 ? get_post($front_page_id)
1660 : (get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null);
1661 if ($home_page) {
1662 return !metasync_would_page_return_404($home_page->ID, $route);
1663 }
1664 }
1665
1666 # Could not verify availability from local data (custom archive, paginated page, etc.).
1667 # Assume the URL IS available — OTTO only crawls reachable URLs, so if we can't
1668 # prove it's a 404, we should not auto-exclude it.
1669 return true;
1670 }
1671
1672 /**
1673 * Recheck auto-excluded 404 URLs when recheck_after has passed; remove from exclusion if now available
1674 * Uses recheck_after timestamp (default 7 days from exclusion) to decide when to recheck
1675 * Mark as permanent after 30 days if still 404 (no further rechecks)
1676 * Called by daily cron job
1677 */
1678 function metasync_otto_recheck_404_exclusions()
1679 {
1680 try {
1681 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1682 $db = new Metasync_Otto_Excluded_URLs_Database();
1683 $records = $db->get_auto_excluded_404_urls_due_for_recheck();
1684
1685 if (empty($records)) {
1686 return;
1687 }
1688
1689 $removed = 0;
1690 $marked_permanent = 0;
1691 $thirty_days_ago = strtotime('-30 days');
1692 $next_recheck = date('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS);
1693
1694 foreach ($records as $record) {
1695 $url = trim($record->url_pattern);
1696 if (empty($url)) {
1697 continue;
1698 }
1699 if (metasync_otto_is_url_available($url)) {
1700 $db->delete([$record->id]);
1701 $removed++;
1702 } else {
1703 # Still 404: if excluded 30+ days ago, mark as permanent (no more rechecks)
1704 $created_ts = strtotime($record->created_at);
1705 if ($created_ts <= $thirty_days_ago) {
1706 $db->update(['is_permanent' => 1], $record->id);
1707 $marked_permanent++;
1708 } else {
1709 # Schedule next recheck in 7 days
1710 $db->update(['recheck_after' => $next_recheck], $record->id);
1711 }
1712 }
1713 }
1714
1715 if ($removed > 0) {
1716 error_log("MetaSync OTTO: Recheck 404 exclusions - removed {$removed} URL(s) that are now available");
1717 }
1718 if ($marked_permanent > 0) {
1719 error_log("MetaSync OTTO: Recheck 404 exclusions - marked {$marked_permanent} URL(s) as permanent (still 404 after 30 days)");
1720 }
1721 } catch (Exception $e) {
1722 error_log('MetaSync OTTO: Recheck 404 exclusions failed - ' . $e->getMessage());
1723 }
1724 }
1725
1726 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
1727
1728 /**
1729 * Check if a post/page would return 404 without making HTTP request
1730 * Uses WordPress database checks for fast validation
1731 *
1732 * @param int $post_id WordPress post ID
1733 * @param string $url The URL being checked (optional, for logging)
1734 * @return bool True if page would return 404, false if accessible
1735 */
1736 function metasync_would_page_return_404($post_id, $url = '') {
1737 if (!$post_id || $post_id <= 0) {
1738 return true; // No post ID = 404
1739 }
1740
1741 # Get the post object
1742 $post = get_post($post_id);
1743 if (!$post) {
1744 return true; // Post doesn't exist = 404
1745 }
1746
1747 # 1. Check post status - must be 'publish' to be publicly accessible
1748 if ($post->post_status !== 'publish') {
1749 return true; // Draft, pending, private, etc. = 404
1750 }
1751
1752 # 2. Check if post is password protected (requires password to view)
1753 if (!empty($post->post_password)) {
1754 # Password protected posts are not publicly accessible without password
1755 return true; // Password protected = effectively 404 for public
1756 }
1757
1758 # 3. Check if post is in trash
1759 if ($post->post_status === 'trash') {
1760 return true; // Trashed = 404
1761 }
1762
1763 # 4. Check if post type is publicly queryable
1764 $post_type_object = get_post_type_object($post->post_type);
1765 if ($post_type_object && !$post_type_object->publicly_queryable) {
1766 # Some post types might not be publicly accessible
1767 # But we allow if it's in our supported types
1768 $supported_post_types = metasync_get_supported_post_types();
1769 if (!in_array($post->post_type, $supported_post_types)) {
1770 return true; // Not publicly queryable = 404
1771 }
1772 }
1773
1774 # 5. WordPress 5.7+ has a built-in function for this
1775 if (function_exists('is_post_publicly_viewable')) {
1776 if (!is_post_publicly_viewable($post)) {
1777 return true; // Not publicly viewable = 404
1778 }
1779 }
1780
1781 # 6. Check if post is scheduled for future (not yet published)
1782 if ($post->post_date > current_time('mysql')) {
1783 return true; // Future post = 404 until publish date
1784 }
1785
1786 # All checks passed - page should be accessible
1787 return false;
1788 }
1789
1790 /**
1791 * Check if a taxonomy term (category, tag, etc.) would return 404
1792 * Uses WordPress database checks for fast validation
1793 *
1794 * @param int $term_id Term ID
1795 * @param string $taxonomy Taxonomy name (e.g., 'category', 'product_cat')
1796 * @param string $url The URL being checked (optional, for logging)
1797 * @return bool True if term would return 404, false if accessible
1798 */
1799 function metasync_would_term_return_404($term_id, $taxonomy, $url = '') {
1800 if (!$term_id || $term_id <= 0 || empty($taxonomy)) {
1801 return true; // Invalid term = 404
1802 }
1803
1804 # Get the term object
1805 $term = get_term($term_id, $taxonomy);
1806 if (is_wp_error($term) || !$term) {
1807 return true; // Term doesn't exist = 404
1808 }
1809
1810 # Check if taxonomy is publicly queryable
1811 $taxonomy_object = get_taxonomy($taxonomy);
1812 if (!$taxonomy_object || !$taxonomy_object->public) {
1813 # Check if it's in our supported taxonomies
1814 $supported_taxonomies = metasync_get_supported_taxonomies();
1815 if (!in_array($taxonomy, $supported_taxonomies)) {
1816 return true; // Not publicly queryable = 404
1817 }
1818 }
1819
1820 # Terms are generally always accessible if they exist and taxonomy is public
1821 # WordPress doesn't have a "draft" status for terms like posts do
1822 # But we can check if the term has a count (has posts assigned)
1823 # Empty terms might not be useful, but they're still accessible
1824
1825 # All checks passed - term should be accessible
1826 return false;
1827 }
1828
1829 /**
1830 * Invalidate Brizy posts cache when posts are saved
1831 * OPTIMIZATION: Clears transient cache to ensure accurate detection
1832 */
1833 add_action('save_post', function($post_id) {
1834 # Check if this post has Brizy metadata
1835 if (get_post_meta($post_id, 'brizy_post_uid', true)) {
1836 delete_transient('metasync_has_brizy_posts');
1837 }
1838 }, 10, 1);