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

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