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

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