PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.6
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.6
2.7.1 2.7.2 2.7.0 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 All 141 releases
metasync / otto / otto_pixel.php

otto_pixel.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.6, at otto/otto_pixel.php

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