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

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