PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.8
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.8
2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / otto / otto_pixel.php

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

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