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

2,092 lines 90.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 #
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
312 # ── WooCommerce-independent cart/checkout protection (WP-374) ──────────
313 # The is_cart()/is_checkout() guards below only recognize WooCommerce. Carts
314 # from other systems — e.g. the Point of Rental "Catalog" plugin on
315 # venturarental.com — are invisible to them, so OTTO would process those
316 # pages and let caching layers store them. A cached cart page corrupts the
317 # live cart (added items vanish because a stale page is served). The checks
318 # here do not depend on WooCommerce being active.
319
320 # Never run OTTO on non-GET requests. SSR exists for crawlers, which only
321 # issue GET; POST/PUT/etc. are form or cart submissions that must pass
322 # through untouched. (OTTO's own internal fetches use GET.)
323 if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
324 return;
325 }
326
327 # Skip OTTO on cart/checkout-type paths regardless of the cart plugin, and
328 # signal caching plugins (WP Rocket/Kinsta/etc.) to never cache them — a
329 # cached cart/checkout page is precisely what corrupts cart state.
330 # Matched against the FIRST path segment (relative to the WP home path) so
331 # subdirectory installs (/shop/cart) still work, while unrelated content pages
332 # like /blog/cart or /features/checkout do NOT false-match.
333 # Drop the query string with explode() (no shared internal pointer like strtok),
334 # then lower-case and strip surrounding slashes.
335 $otto_req_path = explode('?', (string) wp_unslash($_SERVER['REQUEST_URI'] ?? ''), 2)[0];
336 $otto_req_path = strtolower(trim($otto_req_path, '/'));
337 # Strip the site's base path so first-segment matching works on subdir installs.
338 $otto_home_path = trim((string) parse_url(home_url(), PHP_URL_PATH), '/');
339 if ($otto_home_path !== '' && strpos($otto_req_path, $otto_home_path . '/') === 0) {
340 $otto_req_path = substr($otto_req_path, strlen($otto_home_path) + 1);
341 }
342 $otto_first_segment = explode('/', $otto_req_path, 2)[0];
343 # Filterable so sites can add/remove transactional slugs without code changes.
344 $otto_cart_segments = apply_filters('metasync_otto_cart_paths', array(
345 'cart', 'checkout', 'basket', 'request-a-quote', 'quote-request',
346 ));
347 $otto_cart_segments = array_map('strtolower', (array) $otto_cart_segments);
348 if ($otto_first_segment !== '' && in_array($otto_first_segment, $otto_cart_segments, true)) {
349 if (!defined('DONOTCACHEPAGE')) {
350 define('DONOTCACHEPAGE', true);
351 }
352 return;
353 }
354 # ──────────────────────────────────────────────────────────────────────
355
356 if (
357 # disable ajax calls
358 isset($_GET['ucfrontajaxaction']) ||
359 # OTTO Preview mode - skip OTTO when previewing original content
360 (isset($_GET['otto_preview']) && $_GET['otto_preview'] === '1') ||
361 # WooCommerce shop archive page only (products and categories now use SSR)
362 //(function_exists('is_shop') && is_shop()) ||
363 # Cart page
364 (function_exists('is_cart') && is_cart()) ||
365 # Checkout page
366 (function_exists('is_checkout') && is_checkout()) ||
367 # My Account page
368 //(function_exists('is_account_page') && is_account_page()) ||
369 # Standard WordPress AJAX
370 (function_exists('wp_doing_ajax') && wp_doing_ajax()) ||
371 # check by constant
372 (defined('DOING_AJAX') && DOING_AJAX) ||
373 # WooCommerce AJAX endpoint (e.g., ?wc-ajax=update_cart)
374 (isset($_REQUEST['wc-ajax']) && !empty($_REQUEST['wc-ajax'])) ||
375 # AJAX requests via X-Requested-With header
376 (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') ||
377 # Gravity Forms submission detection - skip OTTO to allow form processing
378 (isset($_POST['gform_submit']) && (
379 is_array($_POST['gform_submit']) ||
380 (is_string($_POST['gform_submit']) && isset($_POST['is_submit_' . $_POST['gform_submit']]) && !empty($_POST['gform_submit']))
381 )) ||
382 # Gravity Forms AJAX submission
383 (isset($_POST['gform_ajax']) && isset($_POST['gform_submit']) && (
384 is_array($_POST['gform_submit']) || !empty($_POST['gform_submit'])
385 )) ||
386 # Gravity Forms file upload
387 (isset($_POST['gform_uploaded_files'])) ||
388 # Any Gravity Forms POST parameter
389 (isset($_POST['gform_submit']) || isset($_POST['gform_unique_id']) || isset($_POST['gform_field_values'])) ||
390 # Formidable Forms AJAX submission detection - skip OTTO to allow form processing
391 (isset($_POST['action']) && $_POST['action'] === 'frm_entries_create') ||
392 # Formidable Forms POST parameters
393 (isset($_POST['form_id']) && !empty($_POST['form_id'])) ||
394 # Formidable Forms action parameter
395 (isset($_POST['frm_action']) && !empty($_POST['frm_action'])) ||
396 # Formidable Forms item_key (used in form submissions)
397 (isset($_POST['item_key']) && !empty($_POST['item_key']))
398 ) {
399 return;
400 }
401
402 # fetch globals
403 global $metasync_options, $otto_enabled;
404
405 # OPTIMIZED: check for the disable otto for logged in users option using cached config
406 if(Metasync_Otto_Config::is_disabled_for_loggedin()){
407
408 # get user
409 $current_user = wp_get_current_user();
410
411 # check if user is logged in
412 if( !empty($current_user->ID)){
413
414 return;
415 }
416 }
417
418 # check if current URL is manually excluded from OTTO
419 # NOTE: auto-exclusions (false-positive 404 detections) are intentionally NOT checked
420 # here — they must not block OTTO rendering. Use metasync_is_otto_url_excluded() only
421 # in the webhook handler where we gate SEO meta writes.
422 $request_uri = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
423 $current_url = home_url(strtok($request_uri, '?') ?: $request_uri);
424 if (metasync_is_otto_url_manually_excluded($current_url)) {
425 return;
426 }
427
428 # Skip OTTO for XML endpoints (sitemaps, RSS-as-xml, etc.). OTTO has no
429 # suggestions for machine-readable XML, and the upstream API returns
430 # API_ERROR for these URLs — which then stamps misleading
431 # X-MetaSync-OTTO-Cache: API_ERROR / X-MetaSync-OTTO-Method: NONE headers
432 # onto otherwise-healthy sitemap responses and causes false-alarm bug
433 # reports from customers. See WP-353.
434 $request_path = strtok($request_uri, '?');
435 if ($request_path && preg_match('#\.xml$#i', $request_path)) {
436 return;
437 }
438
439 # BOT DETECTION: Always detect bots so crawl data reaches the SA backend.
440 # Real bots don't execute JS, so otto-tracker.js never fires for them.
441 # push_crawl_log_to_sa() fires a non-blocking wp_remote_post here instead.
442 $bot_detector = Metasync_Otto_Bot_Detector::get_instance();
443 $detection = $bot_detector->detect();
444 if ( $detection['is_bot'] ) {
445 $bot_detector->push_crawl_log_to_sa( $detection, $current_url );
446 }
447
448 # Throttle OTTO rendering for non-search-engine bots: at most one render
449 # per URL+bot every 5 minutes. Humans and verified search engines
450 # (Googlebot, Bingbot, etc.) are never throttled — their hits always get
451 # full OTTO content so indexing remains current. SEO tools, AI scrapers,
452 # uptime monitors, and unverified crawlers are de-duplicated so repeat
453 # hits don't re-trigger the expensive DOM-rewriting path.
454 if ( $detection['is_bot'] && $detection['bot_type'] !== 'search_engine' ) {
455 $normalized_url = strtok( $current_url, '?' );
456 if ( $normalized_url === false ) {
457 $normalized_url = $current_url;
458 }
459 // Lowercase + length cap so attacker-controlled bot_name variations
460 // (EvilBota, EvilBotB, EvilBotc, ...) collapse to a bounded key space
461 // and cannot flood wp_options with unique transient rows.
462 $bot_name = substr( strtolower( $detection['bot_name'] ?? 'unknown' ), 0, 32 );
463 $render_throttle_key = 'metasync_otto_rendered_' . md5( $normalized_url . '|' . $bot_name );
464 if ( get_transient( $render_throttle_key ) ) {
465 // Prevent page caches (WP Rocket, W3TC, etc.) from saving the
466 // un-OTTO'd response and serving it to subsequent human visitors.
467 if ( ! defined( 'DONOTCACHEPAGE' ) ) {
468 define( 'DONOTCACHEPAGE', true );
469 }
470 return;
471 }
472 set_transient( $render_throttle_key, 1, 5 * MINUTE_IN_SECONDS );
473 }
474
475 # Optionally skip OTTO processing for bot traffic (when the setting is enabled)
476 if ($bot_detector->should_skip_otto()) {
477 // Log the bot locally and count the saved API call
478 $bot_detector->log_detection($detection);
479 $bot_stats_db = Metasync_Otto_Bot_Statistics_Database::get_instance();
480 $bot_stats_db->increment_api_calls_saved();
481
482 // Skip OTTO processing for this bot
483 return;
484 }
485
486 # OPTIMIZED: Check if Otto should be disabled for WP Rocket compatibility
487 if (class_exists('WP_Rocket')) {
488 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
489
490 if ($wp_rocket_compat_mode === 'disable_otto') {
491 return; # Exit early, Otto is disabled when WP Rocket is active
492 }
493 }
494
495 # Skip OTTO for Divi AJAX pagination and paginated archive requests (WP-315).
496 # ?et_blog = Divi AJAX pagination callback
497 # /page/N/ = paginated blog/archive pages — OTTO's buffer/HTTP render causes
498 # module numbering mismatch between page 1 (with TB template) and page N
499 # (without TB template), breaking Divi's JS pagination selector matching.
500 if (isset($_GET['et_blog']) || (is_paged() && !is_singular())) {
501 return;
502 }
503
504 # Handle cache plugin compatibility early - before any caching happens
505 metasync_otto_handle_cache_compatibility();
506
507 # check if OTTO is disabled for this specific page/post
508 $post_id = get_the_ID();
509 if ($post_id && class_exists('Metasync_Otto_Frontend_Toolbar')) {
510 if (Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id)) {
511 return;
512 }
513 }
514
515 # check if we are having an otto request
516 if(!empty($_GET['is_otto_page_fetch'])){
517
518 # Block SEO plugins NOW for this internal fetch request
519 # metasync_otto_block_seo_plugins();
520 # $_SERVER['REQUEST_URI'] = remove_query_arg('is_otto_page_fetch', $_SERVER['REQUEST_URI']);
521 $block_title = !empty($_GET['otto_block_title']) && $_GET['otto_block_title'] === '1';
522 $block_description = !empty($_GET['otto_block_desc']) && $_GET['otto_block_desc'] === '1';
523
524 # Block SEO plugins conditionally based on what Otto has
525 if ($block_title || $block_description) {
526 metasync_otto_block_seo_plugins($block_title, $block_description);
527 }
528
529 # Remove ALL Otto parameters from REQUEST_URI to prevent them from appearing in pagination, etc.
530 $request_uri_raw = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
531 $_SERVER['REQUEST_URI'] = remove_query_arg(
532 ['is_otto_page_fetch', 'otto_block_title', 'otto_block_desc'],
533 $request_uri_raw
534 );
535
536 # Also remove from $_GET to prevent WordPress from using them
537 unset($_GET['is_otto_page_fetch']);
538 unset($_GET['otto_block_title']);
539 unset($_GET['otto_block_desc']);
540 return;
541 }
542
543 # to avoid unnecessary processese
544 # OPTIMIZED: check that otto is configured
545 # And the UUID is properly set before running OTT
546
547 # check that we have the option
548 if(!Metasync_Otto_Config::is_otto_enabled()){
549 return;
550 }
551
552 # check that otto is enabled
553 if(!$otto_enabled){
554 return;
555 }
556
557 # get the otto uuid
558 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
559
560 # start the class
561 $otto = new Metasync_otto_pixel($otto_uuid);
562
563 # call render
564 $otto->render_route_html();
565 }
566
567 /**
568 * Handle cache plugin compatibility with Otto
569 * Controls DONOTCACHEPAGE constant based on active plugins and configuration
570 * This function is called early in the WordPress lifecycle
571 */
572 function metasync_otto_handle_cache_compatibility() {
573 # Detect active plugins
574 $brizy_active = class_exists('Brizy_Editor') || defined('BRIZY_VERSION');
575 $wp_rocket_active = class_exists('WP_Rocket');
576 $sg_optimizer_active = is_plugin_active('sg-cachepress/sg-cachepress.php');
577
578 # OPTIMIZED: Get configuration option using cached config
579 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
580
581 # Check for Brizy posts in database
582 global $wpdb;
583 $has_brizy_posts = false;
584
585 if ($brizy_active) {
586 # OPTIMIZED: Check cache first (1-hour TTL) to avoid querying on every page load
587 $cached = get_transient('metasync_has_brizy_posts');
588 if ($cached !== false) {
589 $has_brizy_posts = ($cached === 'yes');
590 } else {
591 # Query database only if cache missed
592 $has_brizy_posts = $wpdb->get_var(
593 "SELECT COUNT(*) FROM {$wpdb->postmeta}
594 WHERE meta_key = 'brizy_post_uid'
595 AND meta_value != ''
596 LIMIT 1"
597 );
598
599 # Cache result for 1 hour
600 $result = !empty($has_brizy_posts) ? 'yes' : 'no';
601 set_transient('metasync_has_brizy_posts', $result, HOUR_IN_SECONDS);
602 }
603 }
604
605 # Determine if DONOTCACHEPAGE should be set
606 $should_set_donotcachepage = false;
607
608 # Case 1: Brizy is active with posts - always needed
609 if ($brizy_active && !empty($has_brizy_posts)) {
610 $should_set_donotcachepage = true;
611 }
612
613 # Case 2: SG Optimizer without WP Rocket - prevent conflicts
614 elseif ($sg_optimizer_active && !$wp_rocket_active) {
615 $should_set_donotcachepage = true;
616 }
617
618 # Case 3: User explicitly disabled Otto for WP Rocket compatibility
619 elseif ($wp_rocket_active && $wp_rocket_compat_mode === 'disable_otto') {
620 $should_set_donotcachepage = true;
621 return; # Exit early, Otto won't run
622 }
623
624 # Case 4: WP Rocket active with auto/buffer mode - DON'T set DONOTCACHEPAGE
625 # This allows WP Rocket optimizations to continue working
626
627 # Only set DONOTCACHEPAGE if needed
628 if ($should_set_donotcachepage && !defined('DONOTCACHEPAGE')) {
629 define('DONOTCACHEPAGE', true);
630 }
631
632 # Apply SG Optimizer specific filters only if SG is active and no WP Rocket
633 if ($sg_optimizer_active && !$wp_rocket_active && $should_set_donotcachepage) {
634 if (!defined('SG_CachePress_SUPERCACHER')) {
635 define('SG_CachePress_SUPERCACHER', false);
636 }
637
638 add_filter('sgo_html_cache_disable', '__return_true', 999);
639 add_filter('sgo_css_combine_exclude', '__return_true', 999);
640 add_filter('sgo_js_combine_exclude', '__return_true', 999);
641 add_filter('sgo_cache_this_page', '__return_false', 999);
642
643 if (!headers_sent()) {
644 header('Cache-Control: no-cache, must-revalidate, max-age=0');
645 header('X-Accel-Expires: 0');
646 }
647 }
648 }
649
650 /**
651 * Block SEO plugins conditionally based on what Otto is providing
652 * Only blocks title if Otto has title, only blocks description if Otto has description
653 * This prevents duplicate SEO tags while allowing fallback to SEO plugins when Otto has no data.
654 * Supports Yoast SEO, Rank Math, and AIOSEO (free + pro).
655 *
656 * @param bool $block_title Whether to block title tags.
657 * @param bool $block_description Whether to block description tags.
658 * @param array $description_tags Optional. Granular list of OTTO-provided description tags
659 * (e.g. ['meta[name=description]', 'meta[property=og:description]']).
660 * When provided, only matching AIOSEO tags are suppressed.
661 * When empty, all AIOSEO description tags are suppressed (legacy behavior).
662 */
663 function metasync_otto_block_seo_plugins($block_title = false, $block_description = false, $description_tags = []) {
664 # Disable Yoast SEO (free and premium)
665 if (is_plugin_active('wordpress-seo/wp-seo.php') ||
666 is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {
667
668 # TITLE: Never block Yoast's title output during SSR fetch.
669 # Yoast removes WordPress's native _wp_render_title_tag action and is the sole
670 # renderer of the <title> tag. Returning false/empty from wpseo_title or removing
671 # Title_Presenter leaves the page with NO <title> tag at all — OTTO's buffer
672 # post-processing then has nothing to replace, producing a missing title.
673 # Instead, let Yoast render its own title; OTTO's replace_title() will overwrite
674 # it in the final HTML buffer. deduplicate_title_tags() cleans up any duplicates.
675
676 # Block description only if Otto has description
677 if ($block_description) {
678 add_filter('wpseo_metadesc', '__return_false', 999);
679 add_filter('wpseo_meta_description', '__return_false', 999);
680 add_filter('wpseo_metakeywords', '__return_false', 999);
681 }
682
683 # Block Yoast's modern presenters — description only, never title
684 add_filter('wpseo_frontend_presenters', function($presenters) use ($block_description) {
685 if (!is_array($presenters)) return $presenters;
686
687 $presenters_to_remove = [];
688
689
690 # Remove description presenters only when OTTO has a description
691 if ($block_description) {
692 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Meta_Description_Presenter';
693 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Open_Graph\Description_Presenter';
694 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Twitter\Description_Presenter';
695 }
696
697 foreach ($presenters as $key => $presenter) {
698 // Safely get class name, suppressing autoload errors
699 // This prevents warnings when Composer autoloader tries to load deprecated Yoast files
700 $class_name = is_object($presenter) ? @get_class($presenter) : '';
701
702 if (!empty($class_name) && in_array($class_name, $presenters_to_remove)) {
703 unset($presenters[$key]);
704 }
705 }
706 return $presenters;
707 }, 999);
708 }
709
710 # Disable Rank Math
711 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
712 is_plugin_active('seo-by-rankmath/rank-math.php')) {
713
714 if ($block_title) {
715 add_filter('rank_math/frontend/title', '__return_empty_string', 999);
716 }
717
718 if ($block_description) {
719 add_filter('rank_math/frontend/description', '__return_false', 999);
720 add_filter('rank_math/frontend/show_keywords', '__return_false', 999);
721 }
722 }
723
724 # Disable AIOSEO (free and pro)
725 if (is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
726 is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
727
728 if ($block_title) {
729 add_filter('aioseo_title', '__return_empty_string', 999);
730 add_filter('aioseo_facebook_tags', function($meta) {
731 if (is_array($meta)) { unset($meta['og:title']); }
732 return $meta;
733 }, 999);
734 add_filter('aioseo_twitter_tags', function($meta) {
735 if (is_array($meta)) { unset($meta['twitter:title']); }
736 return $meta;
737 }, 999);
738 }
739
740 if ($block_description) {
741 # Use granular tag list when available to only block what OTTO provides
742 $tags = !empty($description_tags) ? $description_tags : [];
743 $block_standard = empty($tags) || in_array('meta[name=description]', $tags);
744 $block_og_desc = empty($tags) || in_array('meta[property=og:description]', $tags);
745 $block_tw_desc = empty($tags) || in_array('meta[name=twitter:description]', $tags);
746
747 if ($block_standard) {
748 add_filter('aioseo_description', '__return_empty_string', 999);
749 }
750 if ($block_og_desc) {
751 add_filter('aioseo_facebook_tags', function($meta) {
752 if (is_array($meta)) { unset($meta['og:description']); }
753 return $meta;
754 }, 999);
755 }
756 if ($block_tw_desc) {
757 add_filter('aioseo_twitter_tags', function($meta) {
758 if (is_array($meta)) { unset($meta['twitter:description']); }
759 return $meta;
760 }, 999);
761 }
762 }
763 }
764 }
765 # check that otto is not added via js to the site
766 function metasync_check_otto_js(){
767
768 # Cache result to avoid a blocking HTTP self-request (~2.4s)
769 # on every single admin page load.
770 $cache_key = 'metasync_otto_js_detected';
771 $cached = get_transient($cache_key);
772
773 if ($cached !== false) {
774 return $cached === 'yes';
775 }
776
777 # get the site url
778 $site_url = site_url() . '?is_otto_page_fetch=1';
779
780 # get the html — short timeout so admin doesn't hang
781 $page_data = wp_remote_get($site_url, array('timeout' => 5, 'sslverify' => false));
782
783 if (is_wp_error($page_data)) {
784 set_transient($cache_key, 'no', HOUR_IN_SECONDS);
785 return false;
786 }
787
788 # now get the html body
789 $body = wp_remote_retrieve_body($page_data);
790
791 # now load the body into html
792 $dom = new HtmlDocument($body);
793
794 # now check the dom for a meta tag with
795 $script = $dom->find('script#sa-dynamic-optimization', 0);
796
797 # check script
798 if($script AND !empty($script->getAttribute('data-uuid'))){
799 set_transient($cache_key, 'yes', 12 * HOUR_IN_SECONDS);
800 return true;
801 }
802
803 set_transient($cache_key, 'no', 12 * HOUR_IN_SECONDS);
804 return false;
805 };
806
807 # Handle AJAX Clear Cache request
808 # NOTE: Cache system removed - this is now a no-op
809 function metasync_clear_otto_cache_handler() {
810 if (!empty($_GET['clear_otto_cache'])) {
811 delete_transient('metasync_otto_js_detected');
812 # Cache system has been removed - no cache to clear
813 wp_send_json_success(['message' => 'Cache system removed - all pages processed in real-time']);
814 }
815 else {
816 wp_send_json_error(['message' => 'Missing parameter']);
817 }
818 }
819
820 # Clear cache hook
821 add_action('wp_ajax_metasync_clear_otto_cache', 'metasync_clear_otto_cache_handler');
822
823 # add admin action to check script
824 function metasync_show_otto_ssr_notice() {
825 if (!Metasync::current_user_has_plugin_access()) {
826 return; // Only show to admins
827 }
828
829 # Get the plugin name using centralized method
830 $plugin_name = Metasync::get_effective_plugin_name();
831 $whitelabel_otto_name = Metasync::get_whitelabel_otto_name();
832 if (metasync_check_otto_js()) {
833
834 # Show admin notice with plugin name included in the message
835 echo '<div class="notice notice-error">
836 <p><b>Warning from ' . esc_html($plugin_name) . '</b>
837 <br>
838 ' . 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
839 </p>
840 </div>';
841 }
842 }
843
844 add_action('admin_notices', 'metasync_show_otto_ssr_notice');
845
846
847 # staging dummy change
848 # load otto in the wp hook
849 add_action('wp', 'metasync_start_otto');
850
851 # ENHANCED OTTO SEO INTEGRATION
852 # Register async SEO processing hook
853 add_action('metasync_process_seo_job', 'metasync_process_otto_seo_data', 10, 3);
854 add_action('metasync_process_otto_crawl_url_job', 'metasync_handle_otto_crawl_url_job', 10, 2);
855 add_action('metasync_process_otto_batch_cache_job', 'metasync_handle_otto_batch_cache_job');
856
857 # Process OTTO SEO data and update WordPress meta fields for SEO plugins
858 # This function now runs asynchronously via WordPress cron system
859 #
860 # @param string $route Fully-qualified URL to process.
861 # @param bool $allow_defer When false, skip CPU-deferral rescheduling (used when
862 # called synchronously from crawl_url_job whose own retry
863 # mechanism already handles failures).
864 # @param int $deferral_count How many times this job has already been deferred for
865 # CPU load. Prevents infinite reschedule loops.
866
867 function metasync_process_otto_seo_data($route, $allow_defer = true, $deferral_count = 0) {
868 # Maximum number of times a job can be deferred before it is dropped.
869 $max_deferrals = defined('METASYNC_SEO_JOB_MAX_DEFERRALS') ? METASYNC_SEO_JOB_MAX_DEFERRALS : 5;
870 $lock_key = null;
871 $lock_acquired = false;
872
873 try {
874 # Validate input
875 if (empty($route) || !is_string($route)) {
876 return false;
877 }
878
879 # CPU load check — defer if server is under load.
880 # Only reschedule when called from the cron hook (allow_defer=true) and
881 # we haven't exceeded the maximum deferral count.
882 # Checked BEFORE acquiring the lock so that deferred jobs don't
883 # acquire-then-immediately-release it (which defeats concurrency protection).
884 if (!Metasync_CPU_Monitor::is_load_safe()) {
885 if ($allow_defer && $deferral_count < $max_deferrals) {
886 wp_schedule_single_event(
887 time() + 60,
888 'metasync_process_seo_job',
889 array($route, true, $deferral_count + 1)
890 );
891 return false;
892 }
893 # Max deferrals reached or not allowed to defer — drop the job to
894 # prevent unbounded cron accumulation that causes server overload.
895 if ($deferral_count >= $max_deferrals) {
896 metasync_record_failed_action('metasync_process_seo_job');
897 return false;
898 }
899 # allow_defer=false (sync path): just return false, parent handles retry.
900 return false;
901 }
902
903 # Concurrency lock: prevent multiple SEO jobs from running simultaneously.
904 # Uses a per-URL transient lock with a 120s TTL as a safety net (the lock is
905 # explicitly deleted on completion). If the lock exists another run is already
906 # processing this URL — reschedule once with a short delay instead of stacking.
907 $lock_key = 'metasync_seo_lock_' . md5($route);
908 if (get_transient($lock_key) !== false) {
909 if ($allow_defer && $deferral_count < $max_deferrals) {
910 wp_schedule_single_event(
911 time() + 30,
912 'metasync_process_seo_job',
913 array($route, true, $deferral_count + 1)
914 );
915 }
916 return false;
917 }
918 set_transient($lock_key, true, 120);
919 $lock_acquired = true;
920
921 # Resolve redirect table: use final destination URL before 404 checks and OTTO processing
922 $route = metasync_otto_resolve_redirect_to_final_url($route);
923
924 # Skip excluded URLs - don't process SEO data for them
925 if (metasync_is_otto_url_excluded($route)) {
926 //error_log('MetaSync OTTO: Skipping SEO processing for excluded URL: ' . $route);
927 return false;
928 }
929
930 # Pre-flight 404 check: exclude URLs that would return 404 before making API call
931 if (!metasync_otto_is_url_available($route)) {
932 error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route}");
933 metasync_otto_auto_exclude_404_url($route);
934 return false;
935 }
936
937 # Get OTTO UUID from settings
938 # OPTIMIZED: Use cached options
939 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
940
941 if (empty($otto_uuid)) {
942 return false;
943 }
944
945 # Meta descriptions are always enabled by default - no check needed
946
947 # Fetch SEO data from OTTO API
948 $seo_data = metasync_fetch_otto_seo_data($route, $otto_uuid);
949
950 if (!$seo_data) {
951 metasync_record_failed_action( 'metasync_process_seo_job' );
952 return false;
953 }
954
955 # Mark this URL as crawled by OTTO for SSR
956 # Extract domain and path from route
957 $parsed_url = parse_url($route);
958 $domain_with_scheme = ($parsed_url['scheme'] ?? 'https') . '://' . ($parsed_url['host'] ?? '');
959 $url_path = ($parsed_url['path'] ?? '/');
960
961 # Create crawl data structure
962 $crawl_data = array(
963 'domain' => $domain_with_scheme,
964 'urls' => array($url_path)
965 );
966
967 # Load Otto pixel class and save crawl data
968 $otto_pixel = new Metasync_otto_pixel($otto_uuid);
969 $otto_pixel->save_crawl_data($crawl_data);
970
971 # Get WordPress post ID from URL
972 $post_id = url_to_postid($route);
973
974 # Special handling for WooCommerce shop page (url_to_postid doesn't work for it)
975 if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) {
976 # Check if this URL is the WooCommerce shop page
977 $shop_page_id = wc_get_page_id('shop');
978 if ($shop_page_id > 0) {
979 $shop_url = get_permalink($shop_page_id);
980 $route_normalized = rtrim($route, '/');
981 $shop_url_normalized = rtrim($shop_url, '/');
982
983 if ($route_normalized === $shop_url_normalized) {
984 $post_id = $shop_page_id;
985 }
986 }
987 }
988
989 # Try to find WooCommerce product by URL if url_to_postid failed
990 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
991 # Extract product slug from URL
992 $product_slug = basename(parse_url($route, PHP_URL_PATH));
993
994 # Try to get product by slug
995 $products = wc_get_products(array(
996 'name' => $product_slug,
997 'limit' => 1,
998 'status' => 'publish',
999 ));
1000
1001 if (empty($products)) {
1002 # Fallback: try by slug using WP_Query
1003 $args = array(
1004 'post_type' => 'product',
1005 'name' => $product_slug,
1006 'posts_per_page' => 1,
1007 'post_status' => 'publish',
1008 );
1009 $query = new WP_Query($args);
1010
1011 if ($query->have_posts()) {
1012 $product_post = $query->posts[0];
1013 $post_id = $product_post->ID;
1014 }
1015 } else {
1016 $product = $products[0];
1017 $post_id = $product->get_id();
1018 }
1019 }
1020
1021 if (!$post_id || $post_id <= 0) {
1022 # Check if this is a category page
1023 if (strpos($route, '/category/') !== false) {
1024 # Extract category slug from URL
1025 $category_slug = basename(parse_url($route, PHP_URL_PATH));
1026 $category = get_category_by_slug($category_slug);
1027
1028 if ($category) {
1029 # Check if category would return 404 before applying OTTO changes
1030 if (metasync_would_term_return_404($category->term_id, 'category', $route)) {
1031 error_log("MetaSync OTTO: Skipping SEO processing for category that would return 404: {$route} (Category ID: {$category->term_id})");
1032 metasync_otto_auto_exclude_404_url($route);
1033 return false;
1034 }
1035
1036 # Update comprehensive category SEO meta fields
1037 $update_result = metasync_update_comprehensive_category_seo_fields($category->term_id, $seo_data);
1038
1039 if ($update_result['updated']) {
1040 # Prepare trimmed values to 30 characters
1041 $trim = function($value) {
1042 if ($value === null) { return ''; }
1043 $value = (string) $value;
1044 $value = trim($value);
1045 if (mb_strlen($value) > 30) {
1046 return mb_substr($value, 0, 30);
1047 }
1048 return $value;
1049 };
1050
1051 # Log individual field updates for category
1052 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1053 $short = '';
1054 $title = '';
1055
1056 switch ($field_type) {
1057 case 'meta_title':
1058 $short = $trim($field_value);
1059 $title = "Category Meta Title Update ({$short}...)";
1060 break;
1061 case 'meta_description':
1062 $short = $trim($field_value);
1063 $title = "Category Meta Description Update ({$short}...)";
1064 break;
1065 case 'meta_keywords':
1066 $short = $trim($field_value);
1067 $title = "Category Meta Keywords Update ({$short}...)";
1068 break;
1069 case 'og_title':
1070 $short = $trim($field_value);
1071 $title = "Category Open Graph Title Update ({$short}...)";
1072 break;
1073 case 'og_description':
1074 $short = $trim($field_value);
1075 $title = "Category Open Graph Description Update ({$short}...)";
1076 break;
1077 case 'twitter_title':
1078 $short = $trim($field_value);
1079 $title = "Category Twitter Title Update ({$short}...)";
1080 break;
1081 case 'twitter_description':
1082 $short = $trim($field_value);
1083 $title = "Category Twitter Description Update ({$short}...)";
1084 break;
1085 case 'image_alt_data':
1086 $image_count = count($field_value);
1087 $title = "Category Image Alt Text Update ({$image_count} images)";
1088 break;
1089 case 'headings_data':
1090 $heading_count = count($field_value);
1091 $title = "Category Headings Update ({$heading_count} headings)";
1092 break;
1093 case 'structured_data':
1094 $title = 'Category Structured Data Update';
1095 break;
1096 }
1097
1098 if (!empty($title)) {
1099 metasync_log_sync_history([
1100 'title' => $title,
1101 'source' => 'OTTO SEO',
1102 'status' => 'published',
1103 'content_type' => 'Category SEO',
1104 'url' => $route,
1105 'meta_data' => json_encode([
1106 'field' => $field_type,
1107 'field_value' => $field_value,
1108 'category_id' => $category->term_id,
1109 'category_name' => $category->name
1110 ])
1111 ]);
1112 }
1113 }
1114
1115 return true;
1116 }
1117
1118 return false;
1119 }
1120 }
1121
1122 # Check if this is a WooCommerce product category
1123 if (strpos($route, '/product-category/') !== false) {
1124 # Extract product category slug from URL
1125 $category_slug = basename(parse_url($route, PHP_URL_PATH));
1126 $term = get_term_by('slug', $category_slug, 'product_cat');
1127
1128 if ($term && !is_wp_error($term)) {
1129 # Check if product category would return 404 before applying OTTO changes
1130 if (metasync_would_term_return_404($term->term_id, 'product_cat', $route)) {
1131 error_log("MetaSync OTTO: Skipping SEO processing for product category that would return 404: {$route} (Term ID: {$term->term_id})");
1132 metasync_otto_auto_exclude_404_url($route);
1133 return false;
1134 }
1135
1136 # Update comprehensive taxonomy SEO meta fields
1137 $update_result = metasync_update_comprehensive_taxonomy_seo_fields($term->term_id, 'product_cat', $seo_data);
1138
1139 if ($update_result['updated']) {
1140 # Prepare trimmed values to 30 characters
1141 $trim = function($value) {
1142 if ($value === null) { return ''; }
1143 $value = (string) $value;
1144 $value = trim($value);
1145 if (mb_strlen($value) > 30) {
1146 return mb_substr($value, 0, 30);
1147 }
1148 return $value;
1149 };
1150
1151 # Log individual field updates for product category
1152 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1153 $short = '';
1154 $title = '';
1155
1156 switch ($field_type) {
1157 case 'meta_title':
1158 $short = $trim($field_value);
1159 $title = "Product Category Meta Title Update ({$short}...)";
1160 break;
1161 case 'meta_description':
1162 $short = $trim($field_value);
1163 $title = "Product Category Meta Description Update ({$short}...)";
1164 break;
1165 case 'meta_keywords':
1166 $short = $trim($field_value);
1167 $title = "Product Category Meta Keywords Update ({$short}...)";
1168 break;
1169 case 'og_title':
1170 $short = $trim($field_value);
1171 $title = "Product Category Open Graph Title Update ({$short}...)";
1172 break;
1173 case 'og_description':
1174 $short = $trim($field_value);
1175 $title = "Product Category Open Graph Description Update ({$short}...)";
1176 break;
1177 case 'twitter_title':
1178 $short = $trim($field_value);
1179 $title = "Product Category Twitter Title Update ({$short}...)";
1180 break;
1181 case 'twitter_description':
1182 $short = $trim($field_value);
1183 $title = "Product Category Twitter Description Update ({$short}...)";
1184 break;
1185 case 'image_alt_data':
1186 $image_count = count($field_value);
1187 $title = "Product Category Image Alt Text Update ({$image_count} images)";
1188 break;
1189 case 'headings_data':
1190 $heading_count = count($field_value);
1191 $title = "Product Category Headings Update ({$heading_count} headings)";
1192 break;
1193 case 'structured_data':
1194 $title = 'Product Category Structured Data Update';
1195 break;
1196 }
1197
1198 if (!empty($title)) {
1199 metasync_log_sync_history([
1200 'title' => $title,
1201 'source' => 'OTTO SEO',
1202 'status' => 'published',
1203 'content_type' => 'WooCommerce Product Category SEO',
1204 'url' => $route,
1205 'meta_data' => json_encode([
1206 'field' => $field_type,
1207 'field_value' => $field_value,
1208 'term_id' => $term->term_id,
1209 'term_name' => $term->name,
1210 'taxonomy' => 'product_cat'
1211 ])
1212 ]);
1213 }
1214 }
1215
1216 return true;
1217 }
1218
1219 return false;
1220 }
1221 }
1222
1223 # Check if this is the home page (landing page)
1224 $site_url = rtrim(site_url(), '/');
1225 $route_clean = rtrim($route, '/');
1226
1227 if ($route_clean === $site_url) {
1228 # Get the home page (front page)
1229 $front_page_id = get_option('page_on_front');
1230 $home_page = null;
1231
1232 if ($front_page_id && $front_page_id > 0) {
1233 $home_page = get_post($front_page_id);
1234 } else {
1235 # If no static front page is set, get the latest post
1236 $home_page = get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null;
1237 }
1238
1239 if ($home_page) {
1240 # Check if home page would return 404 before applying OTTO changes
1241 if (metasync_would_page_return_404($home_page->ID, $route)) {
1242 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})");
1243 metasync_otto_auto_exclude_404_url($route);
1244 return false;
1245 }
1246
1247 # Update comprehensive home page SEO meta fields
1248 $update_result = metasync_update_comprehensive_seo_fields($home_page->ID, $seo_data);
1249
1250 if ($update_result['updated']) {
1251 # Clear relevant caches
1252 metasync_clear_post_seo_caches($home_page->ID);
1253
1254 # Prepare trimmed values to 30 characters
1255 $trim = function($value) {
1256 if ($value === null) { return ''; }
1257 $value = (string) $value;
1258 $value = trim($value);
1259 if (mb_strlen($value) > 30) {
1260 return mb_substr($value, 0, 30);
1261 }
1262 return $value;
1263 };
1264
1265 # Log individual field updates for home page
1266 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1267 $short = '';
1268 $title = '';
1269
1270 switch ($field_type) {
1271 case 'meta_title':
1272 $short = $trim($field_value);
1273 $title = "Home Page Meta Title Update ({$short}...)";
1274 break;
1275 case 'meta_description':
1276 $short = $trim($field_value);
1277 $title = "Home Page Meta Description Update ({$short}...)";
1278 break;
1279 case 'meta_keywords':
1280 $short = $trim($field_value);
1281 $title = "Home Page Meta Keywords Update ({$short}...)";
1282 break;
1283 case 'og_title':
1284 $short = $trim($field_value);
1285 $title = "Home Page Open Graph Title Update ({$short}...)";
1286 break;
1287 case 'og_description':
1288 $short = $trim($field_value);
1289 $title = "Home Page Open Graph Description Update ({$short}...)";
1290 break;
1291 case 'twitter_title':
1292 $short = $trim($field_value);
1293 $title = "Home Page Twitter Title Update ({$short}...)";
1294 break;
1295 case 'twitter_description':
1296 $short = $trim($field_value);
1297 $title = "Home Page Twitter Description Update ({$short}...)";
1298 break;
1299 case 'image_alt_data':
1300 $image_count = count($field_value);
1301 $title = "Home Page Image Alt Text Update ({$image_count} images)";
1302 break;
1303 case 'headings_data':
1304 $heading_count = count($field_value);
1305 $title = "Home Page Headings Update ({$heading_count} headings)";
1306 break;
1307 case 'structured_data':
1308 $title = 'Home Page Structured Data Update';
1309 break;
1310 }
1311
1312 if (!empty($title)) {
1313 metasync_log_sync_history([
1314 'title' => $title,
1315 'source' => 'OTTO SEO',
1316 'status' => 'published',
1317 'content_type' => 'Home Page SEO',
1318 'url' => $route,
1319 'meta_data' => json_encode([
1320 'field' => $field_type,
1321 'field_value' => $field_value,
1322 'post_id' => $home_page->ID
1323 ])
1324 ]);
1325 }
1326 }
1327
1328 return true;
1329 }
1330
1331 return false;
1332 }
1333 }
1334
1335 # Check if this is the blog/posts page (page_for_posts)
1336 $posts_page_id = intval(get_option('page_for_posts'));
1337 if ($posts_page_id > 0) {
1338 $posts_page = get_post($posts_page_id);
1339 $posts_page_url = rtrim(get_permalink($posts_page_id), '/');
1340
1341 if ($posts_page && $route_clean === $posts_page_url) {
1342 # Check if blog page would return 404
1343 if (metasync_would_page_return_404($posts_page->ID, $route)) {
1344 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})");
1345 metasync_otto_auto_exclude_404_url($route);
1346 return false;
1347 }
1348
1349 # Update comprehensive blog page SEO meta fields
1350 $update_result = metasync_update_comprehensive_seo_fields($posts_page->ID, $seo_data);
1351
1352 if ($update_result['updated']) {
1353 # Clear relevant caches
1354 metasync_clear_post_seo_caches($posts_page->ID);
1355
1356 # Prepare trimmed values to 30 characters
1357 $trim = function($value) {
1358 if ($value === null) { return ''; }
1359 $value = (string) $value;
1360 $value = trim($value);
1361 if (mb_strlen($value) > 30) {
1362 return mb_substr($value, 0, 30);
1363 }
1364 return $value;
1365 };
1366
1367 # Log individual field updates for blog page
1368 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1369 $short = '';
1370 $title = '';
1371
1372 switch ($field_type) {
1373 case 'meta_title':
1374 $short = $trim($field_value);
1375 $title = "Blog Page Meta Title Update ({$short}...)";
1376 break;
1377 case 'meta_description':
1378 $short = $trim($field_value);
1379 $title = "Blog Page Meta Description Update ({$short}...)";
1380 break;
1381 case 'meta_keywords':
1382 $short = $trim($field_value);
1383 $title = "Blog Page Meta Keywords Update ({$short}...)";
1384 break;
1385 case 'og_title':
1386 $short = $trim($field_value);
1387 $title = "Blog Page Open Graph Title Update ({$short}...)";
1388 break;
1389 case 'og_description':
1390 $short = $trim($field_value);
1391 $title = "Blog Page Open Graph Description Update ({$short}...)";
1392 break;
1393 case 'twitter_title':
1394 $short = $trim($field_value);
1395 $title = "Blog Page Twitter Title Update ({$short}...)";
1396 break;
1397 case 'twitter_description':
1398 $short = $trim($field_value);
1399 $title = "Blog Page Twitter Description Update ({$short}...)";
1400 break;
1401 case 'image_alt_data':
1402 $image_count = count($field_value);
1403 $title = "Blog Page Image Alt Text Update ({$image_count} images)";
1404 break;
1405 case 'headings_data':
1406 $heading_count = count($field_value);
1407 $title = "Blog Page Headings Update ({$heading_count} headings)";
1408 break;
1409 case 'structured_data':
1410 $title = 'Blog Page Structured Data Update';
1411 break;
1412 }
1413
1414 if (!empty($title)) {
1415 metasync_log_sync_history([
1416 'title' => $title,
1417 'source' => 'OTTO SEO',
1418 'status' => 'published',
1419 'content_type' => 'Blog Page SEO',
1420 'url' => $route,
1421 'meta_data' => json_encode([
1422 'field' => $field_type,
1423 'field_value' => $field_value,
1424 'post_id' => $posts_page->ID
1425 ])
1426 ]);
1427 }
1428 }
1429
1430 return true;
1431 }
1432
1433 return false;
1434 }
1435 }
1436
1437 # URL didn't resolve to any supported entity (post, category, home page, blog page)
1438 # Treat as 404 and auto-exclude (e.g. deleted post, non-existent page)
1439 if (!metasync_otto_is_url_available($route)) {
1440 error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404 (no matching entity): {$route}");
1441 metasync_otto_auto_exclude_404_url($route);
1442 }
1443 return false;
1444 }
1445
1446 # Verify this is actually a post, page, or WooCommerce product
1447 $post = get_post($post_id);
1448
1449 # Get supported post types dynamically
1450 $supported_post_types = metasync_get_supported_post_types();
1451
1452 if (!$post || !in_array($post->post_type, $supported_post_types)) {
1453 # Skip unsupported post types
1454 return false;
1455 }
1456
1457 # Check if page would return 404 before applying OTTO changes
1458 if (metasync_would_page_return_404($post_id, $route)) {
1459 error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route} (Post ID: {$post_id}, Status: {$post->post_status})");
1460 metasync_otto_auto_exclude_404_url($route);
1461 return false;
1462 }
1463
1464 # Update comprehensive SEO meta fields
1465 $update_result = metasync_update_comprehensive_seo_fields($post_id, $seo_data);
1466
1467 if ($update_result['updated']) {
1468 # Clear relevant caches
1469 metasync_clear_post_seo_caches($post_id);
1470
1471 # Prepare trimmed values to 30 characters
1472 $trim = function($value) {
1473 if ($value === null) { return ''; }
1474 $value = (string) $value;
1475 $value = trim($value);
1476 if (mb_strlen($value) > 30) {
1477 return mb_substr($value, 0, 30);
1478 }
1479 return $value;
1480 };
1481
1482 # Log individual field updates
1483 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1484 $short = '';
1485 $title = '';
1486
1487 switch ($field_type) {
1488 case 'meta_title':
1489 $short = $trim($field_value);
1490 $title = 'Meta Title Update (' . $short . '...)';
1491 break;
1492 case 'meta_description':
1493 $short = $trim($field_value);
1494 $title = 'Meta Description Update (' . $short . '...)';
1495 break;
1496 case 'meta_keywords':
1497 $short = $trim($field_value);
1498 $title = 'Meta Keywords Update (' . $short . '...)';
1499 break;
1500 case 'og_title':
1501 $short = $trim($field_value);
1502 $title = 'Open Graph Title Update (' . $short . '...)';
1503 break;
1504 case 'og_description':
1505 $short = $trim($field_value);
1506 $title = 'Open Graph Description Update (' . $short . '...)';
1507 break;
1508 case 'twitter_title':
1509 $short = $trim($field_value);
1510 $title = 'Twitter Title Update (' . $short . '...)';
1511 break;
1512 case 'twitter_description':
1513 $short = $trim($field_value);
1514 $title = 'Twitter Description Update (' . $short . '...)';
1515 break;
1516 case 'image_alt_data':
1517 $image_count = count($field_value);
1518 $title = "Image Alt Text Update ({$image_count} images)";
1519 break;
1520 case 'headings_data':
1521 $heading_count = count($field_value);
1522 $title = "Headings Update ({$heading_count} headings)";
1523 break;
1524 case 'structured_data':
1525 $title = 'Structured Data Update';
1526 break;
1527 }
1528
1529 if (!empty($title)) {
1530 metasync_log_sync_history([
1531 'title' => $title,
1532 'source' => 'OTTO SEO',
1533 'status' => 'published',
1534 'content_type' => 'SEO Meta',
1535 'url' => $route,
1536 'meta_data' => json_encode([
1537 'field' => $field_type,
1538 'field_value' => $field_value,
1539 'post_id' => $post_id
1540 ])
1541 ]);
1542 }
1543 }
1544
1545 return true;
1546 }
1547
1548 return false;
1549
1550 } catch (Exception $e) {
1551 metasync_record_failed_action( 'metasync_process_seo_job' );
1552 return false;
1553 } finally {
1554 # Release the concurrency lock only if we actually acquired it.
1555 # Early returns (CPU deferral, lock contention) must NOT delete a lock
1556 # that another process may be holding.
1557 if ($lock_acquired && $lock_key) {
1558 delete_transient($lock_key);
1559 }
1560 }
1561 }
1562
1563 /**
1564 * Log sync history entry
1565 * @param array $data Sync data to log
1566 */
1567 function metasync_log_sync_history($data) {
1568 try {
1569 // Classes are now autoloaded, no need for manual require
1570 $sync_db = new Metasync_Sync_History_Database();
1571
1572 // Minimal duplicate prevention within short time window
1573 if (!empty($data['title']) && !empty($data['source'])) {
1574 global $wpdb;
1575 $table = $wpdb->prefix . Metasync_Sync_History_Database::$table_name;
1576 $recent = $wpdb->get_var($wpdb->prepare(
1577 "SELECT COUNT(*) FROM `$table` WHERE title = %s AND source = %s AND created_at >= %s",
1578 $data['title'],
1579 $data['source'],
1580 gmdate('Y-m-d H:i:s', time() - 60)
1581 ));
1582 if ((int)$recent > 0) {
1583 return; // skip duplicate log within 60 seconds
1584 }
1585 }
1586
1587 $sync_db->add($data);
1588
1589 } catch (Exception $e) {
1590 error_log("MetaSync: Failed to log sync history: " . $e->getMessage());
1591 }
1592 }
1593
1594 /**
1595 * Get supported post types for OTTO SEO optimization
1596 * Includes WooCommerce products if WooCommerce is active
1597 *
1598 * @return array List of supported post types
1599 */
1600 function metasync_get_supported_post_types() {
1601 # Start with default post types
1602 $post_types = ['post', 'page'];
1603
1604 # Add WooCommerce product post type if WooCommerce is active
1605 if (class_exists('WooCommerce') || function_exists('is_woocommerce')) {
1606 $post_types[] = 'product';
1607 }
1608
1609 # Include all public custom post types (e.g. 'location', 'service', 'team', etc.)
1610 # so OTTO can write post meta for them during metasync_process_otto_seo_data().
1611 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
1612 if (!empty($custom_post_types)) {
1613 $post_types = array_merge($post_types, array_values($custom_post_types));
1614 }
1615
1616 # Allow developers to filter supported post types
1617 $post_types = apply_filters('metasync_otto_supported_post_types', $post_types);
1618
1619 return $post_types;
1620 }
1621
1622 /**
1623 * Get supported taxonomies for OTTO SEO optimization
1624 * Includes WooCommerce product categories and tags if WooCommerce is active
1625 *
1626 * @return array List of supported taxonomies
1627 */
1628 function metasync_get_supported_taxonomies() {
1629 # Start with default taxonomies
1630 $taxonomies = ['category'];
1631
1632 # Add WooCommerce taxonomies if WooCommerce is active
1633 if (class_exists('WooCommerce') || function_exists('is_woocommerce')) {
1634 $taxonomies[] = 'product_cat'; # WooCommerce product categories
1635 $taxonomies[] = 'product_tag'; # WooCommerce product tags
1636 }
1637
1638 # Allow developers to filter supported taxonomies
1639 $taxonomies = apply_filters('metasync_otto_supported_taxonomies', $taxonomies);
1640
1641 return $taxonomies;
1642 }
1643
1644 /**
1645 * Resolve a URL through the Redirect Manager table to its final destination (follows redirect chains).
1646 * Used before 404 checks and OTTO processing so the final canonical URL is used, not intermediate redirects.
1647 *
1648 * @param string $url Full URL (e.g. https://example.com/old-page)
1649 * @return string Final destination URL, or original $url if no redirect matches
1650 */
1651 function metasync_otto_resolve_redirect_to_final_url($url)
1652 {
1653 if (empty($url) || !is_string($url)) {
1654 return $url;
1655 }
1656 try {
1657 $db_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection-database.php';
1658 $class_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection.php';
1659 if (!file_exists($db_path) || !file_exists($class_path)) {
1660 return $url;
1661 }
1662 require_once $db_path;
1663 require_once $class_path;
1664 $db = new Metasync_Redirection_Database();
1665 $redirect = new Metasync_Redirection($db);
1666 return $redirect->resolve_url_to_final_destination($url, 10);
1667 } catch (Exception $e) {
1668 error_log('MetaSync OTTO: Redirect resolution failed for ' . $url . ' - ' . $e->getMessage());
1669 return $url;
1670 }
1671 }
1672
1673 /**
1674 * Auto-exclude a URL from OTTO with description "Auto-excluded: 404"
1675 * Called when a URL is detected as returning 404 so it won't be sent to OTTO again
1676 *
1677 * @param string $url Full URL to exclude (e.g. https://example.com/404-page)
1678 * @return bool|string True on success, false on failure, 'duplicate'/'reactivated' if already exists
1679 */
1680 function metasync_otto_auto_exclude_404_url($url)
1681 {
1682 if (empty($url) || !is_string($url)) {
1683 return false;
1684 }
1685 $url = filter_var($url, FILTER_SANITIZE_URL);
1686 $url = esc_url_raw($url);
1687 if (empty($url) || mb_strlen($url) > 2048) {
1688 return false;
1689 }
1690 try {
1691 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1692 $db = new Metasync_Otto_Excluded_URLs_Database();
1693 return $db->add([
1694 'url_pattern' => $url,
1695 'pattern_type' => 'exact',
1696 'description' => 'Auto-excluded: 404',
1697 'status' => 'active',
1698 'auto_excluded' => 1,
1699 ]);
1700 } catch (Exception $e) {
1701 error_log('MetaSync OTTO: Failed to auto-exclude 404 URL: ' . $url . ' - ' . $e->getMessage());
1702 return false;
1703 }
1704 }
1705
1706 /**
1707 * Remove a URL from the OTTO auto-exclusion list.
1708 * Called when OTTO sends a webhook for a URL, confirming it is valid and crawlable.
1709 * Only removes records where auto_excluded = 1 (never removes manual exclusions).
1710 *
1711 * @param string $url Full URL to un-exclude (e.g. https://example.com/location/page)
1712 * @return bool True on success
1713 */
1714 function metasync_otto_remove_auto_exclusion($url)
1715 {
1716 if (empty($url) || !is_string($url)) {
1717 return false;
1718 }
1719 try {
1720 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1721 $db = new Metasync_Otto_Excluded_URLs_Database();
1722 global $wpdb;
1723 $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
1724 # Normalize the same way is_url_excluded() does
1725 $url_normalized = rtrim(trim($url), '/');
1726 $records = $wpdb->get_results(
1727 $wpdb->prepare(
1728 "SELECT id FROM `{$table}` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'",
1729 $url_normalized
1730 )
1731 );
1732 if (!empty($records)) {
1733 $ids = array_map(function ($r) { return (int) $r->id; }, $records);
1734 $db->delete($ids);
1735 }
1736 return true;
1737 } catch (Exception $e) {
1738 return false;
1739 }
1740 }
1741
1742 /**
1743 * Check if a URL is MANUALLY excluded from OTTO (auto_excluded = 0).
1744 * Used at render time (metasync_start_otto) — auto-exclusions must NOT block
1745 * rendering because they are often false positives (e.g. custom post types that
1746 * url_to_postid() can't resolve). Auto-exclusions are only used to gate the
1747 * SEO meta-writing webhook path.
1748 *
1749 * @param string $url URL to check
1750 * @return bool True if URL has a manual exclusion
1751 */
1752 function metasync_is_otto_url_manually_excluded($url)
1753 {
1754 if (empty($url) || !is_string($url)) {
1755 return false;
1756 }
1757 try {
1758 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1759 global $wpdb;
1760 $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
1761 $url_normalized = rtrim(trim($url), '/');
1762
1763 $records = get_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY);
1764
1765 if ($records === false) {
1766 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- no user input, table name from $wpdb->prefix
1767 $records = $wpdb->get_results(
1768 "SELECT url_pattern, pattern_type FROM `{$table}` WHERE status = 'active' AND (auto_excluded = 0 OR auto_excluded IS NULL) ORDER BY created_at DESC"
1769 );
1770
1771 // Graceful recovery: auto_excluded column missing on pre-v2.7.4 installs.
1772 // Run ALTER TABLE to add it and treat URL as not excluded so OTTO continues rendering.
1773 if ($wpdb->last_error && strpos($wpdb->last_error, 'auto_excluded') !== false) {
1774 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1775 $wpdb->query("ALTER TABLE `{$table}` ADD COLUMN `auto_excluded` TINYINT(1) NOT NULL DEFAULT 0");
1776 return false;
1777 }
1778
1779 set_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY, $records ?: [], METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL);
1780 }
1781
1782 if (empty($records)) {
1783 return false;
1784 }
1785
1786 foreach ($records as $excluded) {
1787 $pattern = rtrim(trim($excluded->url_pattern), '/');
1788 $pattern_type = $excluded->pattern_type;
1789
1790 switch ($pattern_type) {
1791 case 'exact':
1792 if ($url_normalized === $pattern) {
1793 return true;
1794 }
1795 break;
1796 case 'contain':
1797 if (strpos($url_normalized, $pattern) !== false) {
1798 return true;
1799 }
1800 break;
1801 case 'start':
1802 if (strpos($url_normalized, $pattern) === 0) {
1803 return true;
1804 }
1805 break;
1806 }
1807 }
1808 return false;
1809 } catch (Exception $e) {
1810 return false;
1811 }
1812 }
1813
1814 /**
1815 * Check if a URL is excluded from OTTO
1816 * @param string $url URL to check
1817 * @return bool True if URL is excluded, false otherwise
1818 */
1819 function metasync_is_otto_url_excluded($url)
1820 {
1821 try {
1822 // Load database class
1823 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1824 $db = new Metasync_Otto_Excluded_URLs_Database();
1825
1826 // Check if URL is excluded
1827 return $db->is_url_excluded($url);
1828
1829 } catch (Exception $e) {
1830 return false;
1831 }
1832 }
1833
1834 /**
1835 * Check if a URL is now available (would NOT return 404)
1836 * Uses same resolution logic as metasync_process_otto_seo_data
1837 * Used when rechecking auto-excluded 404 URLs after 7 days
1838 *
1839 * @param string $url Full URL to check (e.g. https://example.com/page)
1840 * @return bool True if URL is accessible, false if it would return 404
1841 */
1842 function metasync_otto_is_url_available($url)
1843 {
1844 if (empty($url) || !is_string($url)) {
1845 return false;
1846 }
1847
1848 $route = metasync_otto_resolve_redirect_to_final_url($url);
1849 $post_id = url_to_postid($route);
1850
1851 # WooCommerce shop page
1852 if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) {
1853 $shop_page_id = wc_get_page_id('shop');
1854 if ($shop_page_id > 0) {
1855 $shop_url = get_permalink($shop_page_id);
1856 if (rtrim($route, '/') === rtrim($shop_url, '/')) {
1857 $post_id = $shop_page_id;
1858 }
1859 }
1860 }
1861
1862 # WooCommerce product by slug
1863 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
1864 $product_slug = basename(parse_url($route, PHP_URL_PATH));
1865 $products = wc_get_products(array('name' => $product_slug, 'limit' => 1, 'status' => 'publish'));
1866 if (!empty($products)) {
1867 $post_id = $products[0]->get_id();
1868 } else {
1869 $query = new WP_Query(array(
1870 'post_type' => 'product',
1871 'name' => $product_slug,
1872 'posts_per_page' => 1,
1873 'post_status' => 'publish',
1874 ));
1875 if ($query->have_posts()) {
1876 $post_id = $query->posts[0]->ID;
1877 }
1878 }
1879 }
1880
1881 if ($post_id && $post_id > 0) {
1882 $post = get_post($post_id);
1883 # Accept any post type — custom post types (e.g. 'location', 'service') are valid URLs.
1884 # The old in_array check against metasync_get_supported_post_types() caused CPT URLs
1885 # to be wrongly auto-excluded as "404" pages.
1886 if ($post) {
1887 return !metasync_would_page_return_404($post_id, $route);
1888 }
1889 }
1890
1891 # Category
1892 if (strpos($route, '/category/') !== false) {
1893 $category_slug = basename(parse_url($route, PHP_URL_PATH));
1894 $category = get_category_by_slug($category_slug);
1895 if ($category) {
1896 return !metasync_would_term_return_404($category->term_id, 'category', $route);
1897 }
1898 }
1899
1900 # WooCommerce product category
1901 if (strpos($route, '/product-category/') !== false) {
1902 $category_slug = basename(parse_url($route, PHP_URL_PATH));
1903 $term = get_term_by('slug', $category_slug, 'product_cat');
1904 if ($term && !is_wp_error($term)) {
1905 return !metasync_would_term_return_404($term->term_id, 'product_cat', $route);
1906 }
1907 }
1908
1909 # Home page
1910 if (rtrim($route, '/') === rtrim(site_url(), '/')) {
1911 $front_page_id = get_option('page_on_front');
1912 $home_page = ($front_page_id && $front_page_id > 0)
1913 ? get_post($front_page_id)
1914 : (get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null);
1915 if ($home_page) {
1916 return !metasync_would_page_return_404($home_page->ID, $route);
1917 }
1918 }
1919
1920 # Could not verify availability from local data (custom archive, paginated page, etc.).
1921 # Assume the URL IS available — OTTO only crawls reachable URLs, so if we can't
1922 # prove it's a 404, we should not auto-exclude it.
1923 return true;
1924 }
1925
1926 /**
1927 * Recheck auto-excluded 404 URLs when recheck_after has passed; remove from exclusion if now available
1928 * Uses recheck_after timestamp (default 7 days from exclusion) to decide when to recheck
1929 * Mark as permanent after 30 days if still 404 (no further rechecks)
1930 * Called by daily cron job
1931 */
1932 function metasync_otto_recheck_404_exclusions()
1933 {
1934 try {
1935 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
1936 $db = new Metasync_Otto_Excluded_URLs_Database();
1937 $records = $db->get_auto_excluded_404_urls_due_for_recheck();
1938
1939 if (empty($records)) {
1940 return;
1941 }
1942
1943 $removed = 0;
1944 $marked_permanent = 0;
1945 $thirty_days_ago = strtotime('-30 days');
1946 $next_recheck = date('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS);
1947
1948 foreach ($records as $record) {
1949 $url = trim($record->url_pattern);
1950 if (empty($url)) {
1951 continue;
1952 }
1953 if (metasync_otto_is_url_available($url)) {
1954 $db->delete([$record->id]);
1955 $removed++;
1956 } else {
1957 # Still 404: if excluded 30+ days ago, mark as permanent (no more rechecks)
1958 $created_ts = strtotime($record->created_at);
1959 if ($created_ts <= $thirty_days_ago) {
1960 $db->update(['is_permanent' => 1], $record->id);
1961 $marked_permanent++;
1962 } else {
1963 # Schedule next recheck in 7 days
1964 $db->update(['recheck_after' => $next_recheck], $record->id);
1965 }
1966 }
1967 }
1968
1969 if ($removed > 0) {
1970 error_log("MetaSync OTTO: Recheck 404 exclusions - removed {$removed} URL(s) that are now available");
1971 }
1972 if ($marked_permanent > 0) {
1973 error_log("MetaSync OTTO: Recheck 404 exclusions - marked {$marked_permanent} URL(s) as permanent (still 404 after 30 days)");
1974 }
1975 } catch (Exception $e) {
1976 error_log('MetaSync OTTO: Recheck 404 exclusions failed - ' . $e->getMessage());
1977 }
1978 }
1979
1980 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
1981
1982 /**
1983 * Check if a post/page would return 404 without making HTTP request
1984 * Uses WordPress database checks for fast validation
1985 *
1986 * @param int $post_id WordPress post ID
1987 * @param string $url The URL being checked (optional, for logging)
1988 * @return bool True if page would return 404, false if accessible
1989 */
1990 function metasync_would_page_return_404($post_id, $url = '') {
1991 if (!$post_id || $post_id <= 0) {
1992 return true; // No post ID = 404
1993 }
1994
1995 # Get the post object
1996 $post = get_post($post_id);
1997 if (!$post) {
1998 return true; // Post doesn't exist = 404
1999 }
2000
2001 # 1. Check post status - must be 'publish' to be publicly accessible
2002 if ($post->post_status !== 'publish') {
2003 return true; // Draft, pending, private, etc. = 404
2004 }
2005
2006 # 2. Check if post is password protected (requires password to view)
2007 if (!empty($post->post_password)) {
2008 # Password protected posts are not publicly accessible without password
2009 return true; // Password protected = effectively 404 for public
2010 }
2011
2012 # 3. Check if post is in trash
2013 if ($post->post_status === 'trash') {
2014 return true; // Trashed = 404
2015 }
2016
2017 # 4. Check if post type is publicly queryable
2018 $post_type_object = get_post_type_object($post->post_type);
2019 if ($post_type_object && !$post_type_object->publicly_queryable) {
2020 # Some post types might not be publicly accessible
2021 # But we allow if it's in our supported types
2022 $supported_post_types = metasync_get_supported_post_types();
2023 if (!in_array($post->post_type, $supported_post_types)) {
2024 return true; // Not publicly queryable = 404
2025 }
2026 }
2027
2028 # 5. WordPress 5.7+ has a built-in function for this
2029 if (function_exists('is_post_publicly_viewable')) {
2030 if (!is_post_publicly_viewable($post)) {
2031 return true; // Not publicly viewable = 404
2032 }
2033 }
2034
2035 # 6. Check if post is scheduled for future (not yet published)
2036 if ($post->post_date > current_time('mysql')) {
2037 return true; // Future post = 404 until publish date
2038 }
2039
2040 # All checks passed - page should be accessible
2041 return false;
2042 }
2043
2044 /**
2045 * Check if a taxonomy term (category, tag, etc.) would return 404
2046 * Uses WordPress database checks for fast validation
2047 *
2048 * @param int $term_id Term ID
2049 * @param string $taxonomy Taxonomy name (e.g., 'category', 'product_cat')
2050 * @param string $url The URL being checked (optional, for logging)
2051 * @return bool True if term would return 404, false if accessible
2052 */
2053 function metasync_would_term_return_404($term_id, $taxonomy, $url = '') {
2054 if (!$term_id || $term_id <= 0 || empty($taxonomy)) {
2055 return true; // Invalid term = 404
2056 }
2057
2058 # Get the term object
2059 $term = get_term($term_id, $taxonomy);
2060 if (is_wp_error($term) || !$term) {
2061 return true; // Term doesn't exist = 404
2062 }
2063
2064 # Check if taxonomy is publicly queryable
2065 $taxonomy_object = get_taxonomy($taxonomy);
2066 if (!$taxonomy_object || !$taxonomy_object->public) {
2067 # Check if it's in our supported taxonomies
2068 $supported_taxonomies = metasync_get_supported_taxonomies();
2069 if (!in_array($taxonomy, $supported_taxonomies)) {
2070 return true; // Not publicly queryable = 404
2071 }
2072 }
2073
2074 # Terms are generally always accessible if they exist and taxonomy is public
2075 # WordPress doesn't have a "draft" status for terms like posts do
2076 # But we can check if the term has a count (has posts assigned)
2077 # Empty terms might not be useful, but they're still accessible
2078
2079 # All checks passed - term should be accessible
2080 return false;
2081 }
2082
2083 /**
2084 * Invalidate Brizy posts cache when posts are saved
2085 * OPTIMIZATION: Clears transient cache to ensure accurate detection
2086 */
2087 add_action('save_post', function($post_id) {
2088 # Check if this post has Brizy metadata
2089 if (get_post_meta($post_id, 'brizy_post_uid', true)) {
2090 delete_transient('metasync_has_brizy_posts');
2091 }
2092 }, 10, 1);