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

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

2,691 lines 117.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 # 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 // WP-Cron events persist independently of plugin code. A stale or malformed
202 // event may therefore invoke this callback without its required route.
203 // Docblock type isn't enforced at runtime; a stale cron record can pass a non-string.
204 // @phpstan-ignore-next-line function.alreadyNarrowedType
205 if (!is_string($route) || $route === '') {
206 error_log('MetaSync OTTO: skipping crawl-url job without a valid route.');
207 return;
208 }
209
210 try {
211 # Step 1: Warm OTTO transient cache (fetch fresh suggestions from OTTO API into WP transient)
212 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
213 if (!empty($otto_uuid)) {
214 $transient_cache = new Metasync_Otto_Transient_Cache($otto_uuid);
215 $transient_cache->warm_cache($route);
216 }
217
218 # Step 2: Write SEO post meta synchronously BEFORE the cache purge.
219 # This ensures the DB is fully up-to-date when Kinsta (or any host) re-populates
220 # the cache on the very next request — eliminating the race condition where the
221 # 1-second scheduled job hadn't run yet and stale meta got cached.
222 # allow_defer=false: do NOT reschedule a new metasync_process_seo_job cron
223 # event from this synchronous path — the crawl_url_job retry mechanism already
224 # handles failures, and rescheduling here caused an unbounded cron pile-up.
225 metasync_process_otto_seo_data($route, false);
226
227 # Step 3: Clear the per-URL cache entry
228 $otto_pixel = new Metasync_otto_pixel(false);
229 $otto_pixel->refresh_cache($route);
230 Metasync_Cache_Purge::purge_single_url($route);
231
232 } catch (Exception $e) {
233 $max_retries = defined('METASYNC_OTTO_JOB_MAX_RETRIES') ? METASYNC_OTTO_JOB_MAX_RETRIES : 3;
234
235 if ($retry_count < $max_retries) {
236 # Schedule a retry with exponential backoff: 60s, 120s, 240s
237 $delay = 60 * pow(2, $retry_count);
238 wp_schedule_single_event(time() + $delay, 'metasync_process_otto_crawl_url_job', array($route, $retry_count + 1));
239 error_log('MetaSync OTTO: retrying crawl-url job for ' . $route . ' (attempt ' . ($retry_count + 1) . '/' . $max_retries . ') in ' . $delay . 's: ' . $e->getMessage());
240 } else {
241 metasync_record_failed_action('metasync_process_otto_crawl_url_job');
242 error_log('MetaSync OTTO: background crawl-url job permanently failed for ' . $route . ' after ' . $max_retries . ' retries: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
243 }
244 }
245 }
246
247 /**
248 * Batch handler for cache warming and edge CDN purge.
249 *
250 * Runs after all per-URL jobs have had time to complete. Batching these
251 * operations avoids N separate API calls to edge providers (Cloudflare
252 * supports up to 30 tags per request) and lets warm_urls() issue concurrent
253 * fire-and-forget requests.
254 *
255 * @param array $routes List of fully-qualified URLs to warm and purge.
256 */
257 function metasync_handle_otto_batch_cache_job($routes = array()) {
258 // WP-Cron events persist independently of plugin code. A stale or malformed
259 // event may therefore invoke this callback without its required route list.
260 // Docblock type isn't enforced at runtime; a stale cron record can pass a non-array.
261 // @phpstan-ignore-next-line function.alreadyNarrowedType
262 if (!is_array($routes) || empty($routes)) {
263 error_log('MetaSync OTTO: skipping batch cache job without valid routes.');
264 return;
265 }
266
267 try {
268 # Re-populate the cache so OTTO-modified output is what gets stored.
269 # Per-URL host cache was already cleared by individual jobs.
270 # warm_urls() hits each URL with a non-blocking request so our code —
271 # with fresh transients and post meta — is first to populate the host cache.
272 Metasync_Cache_Purge::warm_urls($routes);
273
274 # Purge edge CDN caches (Cloudflare, Fastly, Akamai, Sucuri, Sevalla, etc.)
275 # Tag-based providers purge only the affected posts; full-flush providers fire once per batch.
276 Metasync_Edge_Cache_Purge::purge($routes);
277 } catch (Exception $e) {
278 metasync_record_failed_action('metasync_process_otto_batch_cache_job');
279 error_log('MetaSync OTTO: batch cache job failed for ' . count($routes) . ' URLs: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
280 }
281 }
282
283 # NOTE: Cache system removed - these functions are no longer needed
284 # Kept for backward compatibility in case old cache directories need cleanup
285 function metasync_deleteDir($dir) {
286 if (!is_dir($dir)) {
287 return false;
288 }
289 $files = array_diff(scandir($dir), array('.', '..'));
290 foreach ($files as $file) {
291 $filePath = $dir . DIRECTORY_SEPARATOR . $file;
292 if (is_dir($filePath)) {
293 metasync_deleteDir($filePath);
294 } else {
295 unlink($filePath);
296 }
297 }
298 return rmdir($dir);
299 }
300
301 # Cleanup function for removing old cache directories (if they exist)
302 function metasync_invalidate_all_caches($folder = ''){
303 # Cache system removed - this function only exists to clean up old cache directories
304 if(!defined('WP_CONTENT_DIR')){
305 return false;
306 }
307 $wp_content_dir = WP_CONTENT_DIR;
308 $cache_dir = $wp_content_dir . '/metasync_caches';
309 if(in_array($folder, ['posts', 'pages'])){
310 $cache_dir = $cache_dir . '/' . $folder;
311 }
312 if(is_dir($cache_dir)){
313 metasync_deleteDir($cache_dir);
314 }
315 }
316
317 // metasync_is_custom_or_lps_page() now lives in includes/metasync-helpers.php
318 // (loaded unconditionally before this file) so all SEO surfaces share one rule.
319
320 /**
321 * Detect an Elementor editor / preview request that OTTO must never process.
322 *
323 * Elementor renders its editing canvas in a front-end <iframe> — a logged-in
324 * GET request that is NOT is_admin(), so the admin/ajax/REST guards in
325 * metasync_start_otto() do not catch it. If OTTO output-buffers and rewrites
326 * that response (SimpleHtmlDom in Otto_html_class), the markup the editor's
327 * JavaScript expects is altered and the canvas stays stuck on "loading" —
328 * the editor never finishes initialising. The editor surface is for authoring
329 * only; it is never crawled, so there is nothing for OTTO to optimise there.
330 *
331 * Locally this is masked because the OTTO API is unreachable (API_ERROR ⇒ no
332 * suggestions ⇒ no rewrite); on a live site with real suggestions the rewrite
333 * happens and the editor breaks. See the "Elementor editor fails to load while
334 * the plugin is active" report.
335 *
336 * @return bool True when the current request is an Elementor editor/preview.
337 */
338 function metasync_is_elementor_editor_request() {
339 # The preview iframe loads the front end with ?elementor-preview=POST_ID.
340 # This is the request OTTO would otherwise buffer and corrupt.
341 if (isset($_GET['elementor-preview'])) {
342 return true;
343 }
344
345 # Elementor editor / app entry points carried as an action on the front end.
346 if (isset($_REQUEST['action'])
347 && in_array($_REQUEST['action'], array('elementor', 'elementor_ajax'), true)
348 ) {
349 return true;
350 }
351
352 # Authoritative check when Elementor is loaded: its own preview-mode flag.
353 if (class_exists('\Elementor\Plugin')
354 && isset(\Elementor\Plugin::$instance->preview)
355 && is_object(\Elementor\Plugin::$instance->preview)
356 && method_exists(\Elementor\Plugin::$instance->preview, 'is_preview_mode')
357 && \Elementor\Plugin::$instance->preview->is_preview_mode()
358 ) {
359 return true;
360 }
361
362 return false;
363 }
364
365 function metasync_start_otto(){
366
367 # PERFORMANCE FIX: Cache is now enabled for speed
368 # Skip initial cache cleanup to preserve existing cache
369 # Cache files are valuable for performance - only clear on OTTO updates
370 # Periodic cache clearing can be configured in plugin settings if needed
371
372 # exclude AJAX requests and WooCommerce transactional pages from OTTO SSR
373 # SSR is now ENABLED for: single products, product categories, product tags
374 # SSR is SKIPPED for: cart, checkout
375 # Note: title/description filters (pre_get_document_title, wp_head meta desc)
376 # run on ALL pages regardless — they are hooked unconditionally in seo-functions.php
377
378 # ── WooCommerce-independent cart/checkout protection ──────────
379 # The is_cart()/is_checkout() guards below only recognize WooCommerce. Carts
380 # from other systems — e.g. the Point of Rental "Catalog" plugin on
381 # venturarental.com — are invisible to them, so OTTO would process those
382 # pages and let caching layers store them. A cached cart page corrupts the
383 # live cart (added items vanish because a stale page is served). The checks
384 # here do not depend on WooCommerce being active.
385
386 # Never run OTTO on non-GET requests. SSR exists for crawlers, which only
387 # issue GET; POST/PUT/etc. are form or cart submissions that must pass
388 # through untouched. (OTTO's own internal fetches use GET.)
389 if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
390 return;
391 }
392
393 # Never run OTTO on the Elementor editor/preview iframe. It is a logged-in
394 # front-end GET (not is_admin()), so it slips past the admin guard below;
395 # buffering and rewriting it breaks the editor canvas ("fails to load").
396 if (metasync_is_elementor_editor_request()) {
397 return;
398 }
399
400 # WP core file-editor self-check. When an admin saves a THEME file
401 # via the Plugin/Theme Editor, wp_edit_theme_plugin_file() fires an internal
402 # loopback GET to home_url('/') carrying wp_scrape_key/wp_scrape_nonce to
403 # detect a white-screen. That request is NOT is_admin(), so — exactly like
404 # the Elementor case above — it slips past the admin/AJAX/REST guards below.
405 # OTTO must not output-buffer or rewrite it: the SimpleHtmlDom pass (and any
406 # fatal thrown inside the ob_start() callback, e.g. under memory pressure)
407 # corrupts the scrape and surfaces as the misleading
408 # "preg_match(): Cannot use output buffering in output buffering display
409 # handlers" fatal. Skipping OTTO lets WP render the page normally so the
410 # scrape works. Returning here also avoids the HTTP-fallback path firing a
411 # second loopback for an already-internal self-request.
412 if (metasync_is_scrape_request()) {
413 return;
414 }
415
416 # Skip OTTO on cart/checkout-type paths regardless of the cart plugin, and
417 # signal caching plugins (WP Rocket/Kinsta/etc.) to never cache them — a
418 # cached cart/checkout page is precisely what corrupts cart state.
419 # Matched against the FIRST path segment (relative to the WP home path) so
420 # subdirectory installs (/shop/cart) still work, while unrelated content pages
421 # like /blog/cart or /features/checkout do NOT false-match.
422 # Drop the query string with explode() (no shared internal pointer like strtok),
423 # then lower-case and strip surrounding slashes.
424 $otto_req_path = explode('?', (string) wp_unslash($_SERVER['REQUEST_URI'] ?? ''), 2)[0];
425 $otto_req_path = strtolower(trim($otto_req_path, '/'));
426 # Strip the site's base path so first-segment matching works on subdir installs.
427 $otto_home_path = trim((string) parse_url(home_url(), PHP_URL_PATH), '/');
428 if ($otto_home_path !== '' && strpos($otto_req_path, $otto_home_path . '/') === 0) {
429 $otto_req_path = substr($otto_req_path, strlen($otto_home_path) + 1);
430 }
431 $otto_first_segment = explode('/', $otto_req_path, 2)[0];
432 # Filterable so sites can add/remove transactional slugs without code changes.
433 $otto_cart_segments = apply_filters('metasync_otto_cart_paths', array(
434 'cart', 'checkout', 'basket', 'request-a-quote', 'quote-request',
435 ));
436 $otto_cart_segments = array_map('strtolower', (array) $otto_cart_segments);
437 if ($otto_first_segment !== '' && in_array($otto_first_segment, $otto_cart_segments, true)) {
438 if (!defined('DONOTCACHEPAGE')) {
439 define('DONOTCACHEPAGE', true);
440 }
441 return;
442 }
443 # ──────────────────────────────────────────────────────────────────────
444
445 if (
446 # disable ajax calls
447 isset($_GET['ucfrontajaxaction']) ||
448 # OTTO Preview mode - skip OTTO when previewing original content
449 (isset($_GET['otto_preview']) && $_GET['otto_preview'] === '1') ||
450 # WooCommerce shop archive page only (products and categories now use SSR)
451 //(function_exists('is_shop') && is_shop()) ||
452 # Cart page
453 (function_exists('is_cart') && is_cart()) ||
454 # Checkout page
455 (function_exists('is_checkout') && is_checkout()) ||
456 # My Account page
457 //(function_exists('is_account_page') && is_account_page()) ||
458 # Standard WordPress AJAX
459 (function_exists('wp_doing_ajax') && wp_doing_ajax()) ||
460 # check by constant
461 (defined('DOING_AJAX') && DOING_AJAX) ||
462 # WooCommerce AJAX endpoint (e.g., ?wc-ajax=update_cart)
463 (isset($_REQUEST['wc-ajax']) && !empty($_REQUEST['wc-ajax'])) ||
464 # AJAX requests via X-Requested-With header
465 (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') ||
466 # Gravity Forms submission detection - skip OTTO to allow form processing
467 (isset($_POST['gform_submit']) && (
468 is_array($_POST['gform_submit']) ||
469 (is_string($_POST['gform_submit']) && isset($_POST['is_submit_' . $_POST['gform_submit']]) && !empty($_POST['gform_submit']))
470 )) ||
471 # Gravity Forms AJAX submission
472 (isset($_POST['gform_ajax']) && isset($_POST['gform_submit']) && (
473 is_array($_POST['gform_submit']) || !empty($_POST['gform_submit'])
474 )) ||
475 # Gravity Forms file upload
476 (isset($_POST['gform_uploaded_files'])) ||
477 # Any Gravity Forms POST parameter
478 (isset($_POST['gform_submit']) || isset($_POST['gform_unique_id']) || isset($_POST['gform_field_values'])) ||
479 # Formidable Forms AJAX submission detection - skip OTTO to allow form processing
480 (isset($_POST['action']) && $_POST['action'] === 'frm_entries_create') ||
481 # Formidable Forms POST parameters
482 (isset($_POST['form_id']) && !empty($_POST['form_id'])) ||
483 # Formidable Forms action parameter
484 (isset($_POST['frm_action']) && !empty($_POST['frm_action'])) ||
485 # Formidable Forms item_key (used in form submissions)
486 (isset($_POST['item_key']) && !empty($_POST['item_key']))
487 ) {
488 return;
489 }
490
491 # fetch globals
492 global $metasync_options, $otto_enabled;
493
494 # OPTIMIZED: check for the disable otto for logged in users option using cached config
495 if(Metasync_Otto_Config::is_disabled_for_loggedin()){
496
497 # get user
498 $current_user = wp_get_current_user();
499
500 # check if user is logged in
501 if( !empty($current_user->ID)){
502
503 return;
504 }
505 }
506
507 # check if current URL is manually excluded from OTTO
508 # NOTE: auto-exclusions (false-positive 404 detections) are intentionally NOT checked
509 # here — they must not block OTTO rendering. Use metasync_is_otto_url_excluded() only
510 # in the webhook handler where we gate SEO meta writes.
511 $request_uri = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
512 $current_url = home_url(strtok($request_uri, '?') ?: $request_uri);
513 if (metasync_is_otto_url_manually_excluded($current_url)) {
514 return;
515 }
516
517 # Skip OTTO for XML endpoints (sitemaps, RSS-as-xml, etc.). OTTO has no
518 # suggestions for machine-readable XML, and the upstream API returns
519 # API_ERROR for these URLs — which then stamps misleading
520 # X-MetaSync-OTTO-Cache: API_ERROR / X-MetaSync-OTTO-Method: NONE headers
521 # onto otherwise-healthy sitemap responses and causes false-alarm bug
522 # reports from customers.
523 $request_path = strtok($request_uri, '?');
524 if ($request_path && preg_match('#\.xml$#i', $request_path)) {
525 return;
526 }
527
528 # Machine-readable core endpoints. Using WordPress's own conditionals rather
529 # than matching the path: they are exact, and a path pattern here would also
530 # match ordinary content whose slug merely starts with the same characters
531 # (e.g. /robots.txt-explained/, /favicon.ico-vs-svg/).
532 # is_favicon() is WP 5.4+; this plugin supports 5.2.
533 if (is_robots() || (function_exists('is_favicon') && is_favicon())) {
534 return;
535 }
536
537 # Protocol paths and static assets that are never OTTO-able content.
538 # class_exists guard matches how this function treats the render strategy
539 # further down: if the class is missing we fail open and let the request
540 # proceed rather than fatal.
541 if (class_exists('Metasync_Otto_Render_Strategy')
542 && Metasync_Otto_Render_Strategy::is_non_content_path($request_path)) {
543 return;
544 }
545
546 # BOT DETECTION: Always detect bots so crawl data reaches the SA backend.
547 # Real bots don't execute JS, so otto-tracker.js never fires for them.
548 # push_crawl_log_to_sa() fires a non-blocking wp_remote_post here instead.
549 $bot_detector = Metasync_Otto_Bot_Detector::get_instance();
550 $detection = $bot_detector->detect();
551 if ( $detection['is_bot'] ) {
552 $bot_detector->push_crawl_log_to_sa( $detection, $current_url );
553 }
554
555 # Throttle OTTO rendering for non-search-engine bots: at most one render
556 # per URL+bot every 5 minutes. Humans and verified search engines
557 # (Googlebot, Bingbot, etc.) are never throttled — their hits always get
558 # full OTTO content so indexing remains current. SEO tools, AI scrapers,
559 # uptime monitors, and unverified crawlers are de-duplicated so repeat
560 # hits don't re-trigger the expensive DOM-rewriting path.
561 if ( $detection['is_bot']
562 && $detection['bot_type'] !== 'search_engine'
563 && ! metasync_otto_is_unthrottled_infrastructure_agent( $detection ) ) {
564 $normalized_url = strtok( $current_url, '?' );
565 if ( $normalized_url === false ) {
566 $normalized_url = $current_url;
567 }
568 // Lowercase + length cap so attacker-controlled bot_name variations
569 // (EvilBota, EvilBotB, EvilBotc, ...) collapse to a bounded key space
570 // and cannot flood wp_options with unique transient rows.
571 $bot_name = substr( strtolower( $detection['bot_name'] ?? 'unknown' ), 0, 32 );
572 $render_throttle_key = 'metasync_otto_rendered_' . md5( $normalized_url . '|' . $bot_name );
573 if ( get_transient( $render_throttle_key ) ) {
574 // Prevent page caches (WP Rocket, W3TC, etc.) from saving the
575 // un-OTTO'd response and serving it to subsequent human visitors.
576 if ( ! defined( 'DONOTCACHEPAGE' ) ) {
577 define( 'DONOTCACHEPAGE', true );
578 }
579 return;
580 }
581 set_transient( $render_throttle_key, 1, 5 * MINUTE_IN_SECONDS );
582 }
583
584 # Skip OTTO on 404s.
585 #
586 # OTTO suggestions are keyed to real, crawled pages. Asking the API about a URL
587 # that returns 404 spends a full API_TIMEOUT of PHP-FPM worker time on a
588 # guaranteed-empty answer. Not theoretical: in one production sample, 21 of 54
589 # crawler requests were 404s on URLs that never existed on the site, each
590 # costing ~2s of a worker.
591 #
592 # Deliberately placed AFTER bot detection so push_crawl_log_to_sa() still
593 # reports the crawl to SearchAtlas — that telemetry is how the crawler side
594 # can learn to stop requesting these URLs. Only the expensive suggestions
595 # lookup is skipped.
596 #
597 # Filterable because "should a 404 page get OTTO treatment" is arguably a site
598 # decision, even though there is no known case where it should.
599 if (is_404() && apply_filters('metasync_otto_skip_on_404', true)) {
600 return;
601 }
602
603 # Optionally skip OTTO processing for bot traffic (when the setting is enabled)
604 if ($bot_detector->should_skip_otto()) {
605 // Log the bot locally and count the saved API call
606 $bot_detector->log_detection($detection);
607 $bot_stats_db = Metasync_Otto_Bot_Statistics_Database::get_instance();
608 $bot_stats_db->increment_api_calls_saved();
609
610 // Skip OTTO processing for this bot
611 return;
612 }
613
614 # OPTIMIZED: Check if Otto should be disabled for WP Rocket compatibility
615 if (class_exists('WP_Rocket')) {
616 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
617
618 if ($wp_rocket_compat_mode === 'disable_otto') {
619 return; # Exit early, Otto is disabled when WP Rocket is active
620 }
621 }
622
623 # Skip OTTO for Divi AJAX pagination and paginated archive requests.
624 # ?et_blog = Divi AJAX pagination callback
625 # /page/N/ = paginated blog/archive pages — OTTO's buffer/HTTP render causes
626 # module numbering mismatch between page 1 (with TB template) and page N
627 # (without TB template), breaking Divi's JS pagination selector matching.
628 if (isset($_GET['et_blog']) || (is_paged() && !is_singular())) {
629 return;
630 }
631
632 # Skip OTTO on search-results pages. A search request carries its
633 # meaning entirely in the query string (e.g. /?s=term, or FiboSearch's
634 # /?s=term&post_type=product&dgwt_wcas=1). get_route() intentionally strips
635 # the query string to build a canonical, cache-key-stable route — which
636 # collapses a query-only search URL to the site root ('/'). OTTO then finds
637 # the HOME page's suggestions for that route and, on the HTTP render path,
638 # serves the home page in place of the search results (the address bar keeps
639 # the search URL — it is a silent content swap, not a redirect). This only
640 # surfaces for logged-in / uncached visitors, since page caches serve the
641 # correct pre-rendered search page to everyone else. Search-results pages
642 # have no per-canonical-URL OTTO suggestions of their own, so OTTO must never
643 # run here. The raw $_GET['s'] check is a defensive fallback for setups where
644 # the main query is altered before this point.
645 if (is_search() || !empty($_GET['s'])) {
646 return;
647 }
648
649 # Handle cache plugin compatibility early - before any caching happens
650 metasync_otto_handle_cache_compatibility();
651
652 # check if OTTO is disabled for this specific page/post
653 $post_id = get_the_ID();
654 if ($post_id && class_exists('Metasync_Otto_Frontend_Toolbar')) {
655 if (Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id)) {
656 return;
657 }
658 }
659
660 # Skip OTTO on MetaSync custom HTML / LPS-imported pages — they ship their
661 # own complete, self-contained SEO and OTTO must not inject or overwrite it
662 # with a different/older project's SEO. Resolve the queried object
663 # id (with get_the_ID() fallback) so the static-front-page case — an LPS home
664 # set as the WP front page, where is_page() is false — is still detected.
665 # Applying the skip here, upstream of the single render_route_html() entry,
666 # covers all three render paths (Rocket buffer, output buffer, HTTP fallback).
667 # Only singular views (posts/pages, incl. a static front page) can be a
668 # custom/LPS page; gate on is_singular() so an archive/search/term query
669 # can never have its object id mistaken for a custom page's post id.
670 $custom_page_id = is_singular() ? ( get_queried_object_id() ?: get_the_ID() ) : 0;
671 if (metasync_is_custom_or_lps_page($custom_page_id)) {
672 if (!headers_sent() && Metasync_Otto_Render_Strategy::diagnostics_enabled()) {
673 header('X-MetaSync-OTTO-Method: EXCLUDED');
674 }
675 return;
676 }
677
678 # check if we are having an otto request
679 if(!empty($_GET['is_otto_page_fetch'])){
680
681 # Block SEO plugins NOW for this internal fetch request
682 # metasync_otto_block_seo_plugins();
683 # $_SERVER['REQUEST_URI'] = remove_query_arg('is_otto_page_fetch', $_SERVER['REQUEST_URI']);
684 $block_title = !empty($_GET['otto_block_title']) && $_GET['otto_block_title'] === '1';
685 $block_description = !empty($_GET['otto_block_desc']) && $_GET['otto_block_desc'] === '1';
686
687 # Block SEO plugins conditionally based on what Otto has
688 if ($block_title || $block_description) {
689 metasync_otto_block_seo_plugins($block_title, $block_description);
690 }
691
692 # Remove ALL Otto parameters from REQUEST_URI to prevent them from appearing in pagination, etc.
693 $request_uri_raw = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
694 $_SERVER['REQUEST_URI'] = remove_query_arg(
695 ['is_otto_page_fetch', 'otto_block_title', 'otto_block_desc'],
696 $request_uri_raw
697 );
698
699 # Also remove from $_GET to prevent WordPress from using them
700 unset($_GET['is_otto_page_fetch']);
701 unset($_GET['otto_block_title']);
702 unset($_GET['otto_block_desc']);
703 return;
704 }
705
706 # to avoid unnecessary processese
707 # OPTIMIZED: check that otto is configured
708 # And the UUID is properly set before running OTT
709
710 # check that we have the option
711 if(!Metasync_Otto_Config::is_otto_enabled()){
712 return;
713 }
714
715 # check that otto is enabled
716 if(!$otto_enabled){
717 return;
718 }
719
720 # get the otto uuid
721 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
722
723 # start the class
724 $otto = new Metasync_otto_pixel($otto_uuid);
725
726 # call render
727 $otto->render_route_html();
728 }
729
730 /**
731 * Handle cache plugin compatibility with Otto
732 * Controls DONOTCACHEPAGE constant based on active plugins and configuration
733 * This function is called early in the WordPress lifecycle
734 */
735 function metasync_otto_handle_cache_compatibility() {
736 # Detect active plugins. SG Optimizer detection now lives in
737 # metasync_otto_disable_sg_page_cache(), which is invoked later
738 # once OTTO confirms suggestions for the URL.
739 $brizy_active = class_exists('Brizy_Editor') || defined('BRIZY_VERSION');
740 $wp_rocket_active = class_exists('WP_Rocket');
741
742 # OPTIMIZED: Get configuration option using cached config
743 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
744
745 # Check for Brizy posts in database
746 global $wpdb;
747 $has_brizy_posts = false;
748
749 if ($brizy_active) {
750 # OPTIMIZED: Check cache first (1-hour TTL) to avoid querying on every page load
751 $cached = get_transient('metasync_has_brizy_posts');
752 if ($cached !== false) {
753 $has_brizy_posts = ($cached === 'yes');
754 } else {
755 # Query database only if cache missed
756 $has_brizy_posts = $wpdb->get_var(
757 "SELECT COUNT(*) FROM {$wpdb->postmeta}
758 WHERE meta_key = 'brizy_post_uid'
759 AND meta_value != ''
760 LIMIT 1"
761 );
762
763 # Cache result for 1 hour
764 $result = !empty($has_brizy_posts) ? 'yes' : 'no';
765 set_transient('metasync_has_brizy_posts', $result, HOUR_IN_SECONDS);
766 }
767 }
768
769 # Determine if DONOTCACHEPAGE should be set
770 $should_set_donotcachepage = false;
771
772 # Case 1: Brizy is active with posts - always needed. Brizy pages are
773 # dynamically rendered regardless of OTTO, so caching must be disabled
774 # whenever Brizy posts exist.
775 if ($brizy_active && !empty($has_brizy_posts)) {
776 $should_set_donotcachepage = true;
777 }
778
779 # Case 2: User explicitly disabled Otto for WP Rocket compatibility
780 elseif ($wp_rocket_active && $wp_rocket_compat_mode === 'disable_otto') {
781 $should_set_donotcachepage = true;
782 return; # Exit early, Otto won't run
783 }
784
785 # Case 3: WP Rocket active with auto/buffer mode - DON'T set DONOTCACHEPAGE
786 # This allows WP Rocket optimizations to continue working
787
788 # NOTE: SiteGround SG Optimizer cache bypass is intentionally NOT
789 # handled here. Emitting no-cache headers / disabling SG cache on this hook
790 # fired on EVERY front-end page — including pages OTTO never modifies —
791 # forcing SG to bypass its page cache site-wide and spiking CPU. The SG
792 # bypass is now deferred to metasync_otto_disable_sg_page_cache(), called
793 # from render_route_html() only after OTTO confirms it has suggestions for
794 # the current URL.
795
796 # Only set DONOTCACHEPAGE if needed
797 if ($should_set_donotcachepage && !defined('DONOTCACHEPAGE')) {
798 define('DONOTCACHEPAGE', true);
799 }
800 }
801
802 /**
803 * Disable SiteGround SG Optimizer page caching for the CURRENT request only.
804 *
805 * Called from Metasync_otto_pixel::render_route_html() once OTTO has confirmed
806 * it has suggestions to apply to the requested URL. Scoping the no-cache
807 * override to pages OTTO actually modifies — rather than emitting it on every
808 * front-end page via the unconditional `wp` hook — keeps SG's page cache
809 * working site-wide and avoids the CPU spike reported in.
810 *
811 * Only meaningful on SiteGround sites without WP Rocket; a no-op otherwise.
812 */
813 function metasync_otto_disable_sg_page_cache() {
814 # Only relevant when SG Optimizer is active and WP Rocket is not present.
815 if (class_exists('WP_Rocket')) {
816 return;
817 }
818 if (!is_plugin_active('sg-cachepress/sg-cachepress.php')) {
819 return;
820 }
821
822 if (!defined('DONOTCACHEPAGE')) {
823 define('DONOTCACHEPAGE', true);
824 }
825 if (!defined('SG_CachePress_SUPERCACHER')) {
826 define('SG_CachePress_SUPERCACHER', false);
827 }
828
829 add_filter('sgo_html_cache_disable', '__return_true', 999);
830 add_filter('sgo_css_combine_exclude', '__return_true', 999);
831 add_filter('sgo_js_combine_exclude', '__return_true', 999);
832 add_filter('sgo_cache_this_page', '__return_false', 999);
833
834 if (!headers_sent()) {
835 header('Cache-Control: no-cache, must-revalidate, max-age=0');
836 header('X-Accel-Expires: 0');
837 }
838 }
839
840 /**
841 * How long a response that OTTO could not optimize may be cached, in seconds.
842 *
843 * Deliberately short rather than zero — see
844 * metasync_otto_limit_response_cache() for why.
845 */
846 if (!defined('METASYNC_OTTO_FALLBACK_CACHE_TTL')) {
847 define('METASYNC_OTTO_FALLBACK_CACHE_TTL', 60);
848 }
849
850 /**
851 * Cap how long the current response may be cached, because OTTO could not apply
852 * its suggestions to it.
853 *
854 * OTTO fails open: when suggestions are unavailable, or when the HTML rewrite
855 * fails, the site's own un-optimized markup is served instead. That markup is
856 * valid — the harm is that a caching layer then stores it and serves it to
857 * everyone for the rest of its TTL (a host edge cache with s-maxage=86400 pins
858 * it for a day), which surfaces to customers as the OTTO title "reverting".
859 *
860 * A short TTL is used rather than `no-store`, which is the obvious choice but the
861 * wrong one. Some failures look transient and are not: a page that always breaks
862 * the rewriter, or a host that cannot fetch its own pages, fails identically on
863 * every request. `no-store` would make those permanently uncacheable, sending
864 * 100% of their traffic to the origin forever in exchange for markup that will
865 * never improve. A one-minute ceiling fixes the reported bug just as completely —
866 * a day-long pin becomes a minute — while capping the cost of being wrong about
867 * which failures recover.
868 *
869 * Different layers read different signals, so all of them are emitted:
870 *
871 * - Cache-Control CDNs, host edge caches, browsers. `max-age=0` keeps
872 * browsers revalidating while `s-maxage` lets shared
873 * caches absorb a burst for the ceiling's duration.
874 * - X-Accel-Expires Nginx's own directive. Evaluated with higher
875 * precedence than Cache-Control, and not covered by
876 * the `fastcgi_ignore_headers Cache-Control Expires`
877 * recipe common on managed hosts — so it can survive
878 * a config that discards Cache-Control. Nginx strips
879 * X-Accel-* before the response reaches the client,
880 * so there is no visitor-facing side effect.
881 * - Surrogate-Control Varnish and Fastly. Note that on stock Varnish the
882 * mere presence of this header suppresses its
883 * Cache-Control check, so it must carry a real
884 * lifetime rather than be left at some other value.
885 * - DONOTCACHEPAGE WordPress-level page caches (WP Rocket, W3TC,
886 * LiteSpeed, SG Optimizer). These run inside PHP and
887 * never see response headers. They have no notion of a
888 * short TTL — it is cache or do not — so they skip
889 * entirely. That is safe here because callers only
890 * invoke this for failures expected to resolve; a
891 * permanent one (an oversized page, a host that cannot
892 * reach itself) is left fully cacheable by the caller
893 * and never reaches this function.
894 *
895 * Cache-tagging headers (Surrogate-Key, Cache-Tag, Edge-Cache-Tag) are
896 * deliberately left in place. They are purge-targeting labels, not cacheability
897 * directives — no CDN stores a response *because* a tag is present. On a layer
898 * that stores this response anyway, those tags are the only handle a later purge
899 * has on it, so removing them would discard the recovery path exactly where it
900 * is needed most.
901 *
902 * Some layers may honour none of this; a managed host that overrides origin
903 * cache headers will cache the response regardless. This bounds the damage where
904 * it can and is inert where it cannot.
905 *
906 * @param string $reason Short machine-readable reason, surfaced as a response
907 * header for debugging (e.g. 'API_ERROR', 'RENDER_FAILED').
908 */
909 /**
910 * Build the response headers that cap how long an un-optimized response may be
911 * cached.
912 *
913 * Thin wrapper over Metasync_Otto_Render_Strategy::fallback_cache_headers(), which
914 * owns the header set so it can be unit-tested (this file cannot be loaded in a
915 * test context). Returns an empty array if the strategy class is somehow absent,
916 * so a caller emits nothing rather than an untested divergent copy.
917 *
918 * @param int $ttl Cache ceiling in seconds.
919 * @param string $reason Optional machine-readable reason for the debug header.
920 * @return array Header name => value.
921 */
922 function metasync_otto_fallback_cache_headers($ttl, $reason = '') {
923 if (!class_exists('Metasync_Otto_Render_Strategy')) {
924 return [];
925 }
926
927 return Metasync_Otto_Render_Strategy::fallback_cache_headers_for_response($ttl, $reason);
928 }
929
930 function metasync_otto_limit_response_cache($reason = '') {
931 # This function must only ever make the response LESS cacheable. header()
932 # replaces by default and the capped set carries X-Accel-Expires, which nginx
933 # evaluates ahead of Cache-Control — so emitting it unconditionally could
934 # override an upstream no-store and grant edge caching that nothing had
935 # granted. The strategy reconciles existing cache directives before emitting
936 # a capped set.
937
938 $strategy_available = class_exists('Metasync_Otto_Render_Strategy');
939
940 # 1. Logged-in users. OTTO renders for them unless the "disable on logged in"
941 # setting is switched on, which is off by default — so this is the common
942 # case, not an edge case. Their page may carry session-specific or
943 # membership-gated content, and capping at 60 seconds would still let a
944 # shared cache store and re-serve it to somebody else.
945 if (function_exists('is_user_logged_in') && is_user_logged_in()) {
946 if (!headers_sent() && $strategy_available) {
947 foreach (Metasync_Otto_Render_Strategy::no_store_headers($reason) as $name => $value) {
948 header($name . ': ' . $value);
949 }
950 }
951 if (!defined('DONOTCACHEPAGE')) {
952 define('DONOTCACHEPAGE', true);
953 }
954 return;
955 }
956
957 # 2. Something upstream already restricted this response. Leave its headers
958 # alone — a page deliberately marked no-store must not be handed a 60
959 # second shared-cache lifetime by us.
960 # The strategy turns an existing no-store into a consistent no-store set and
961 # uses the strictest known existing lifetime for every cache layer.
962 $ttl = (int) METASYNC_OTTO_FALLBACK_CACHE_TTL;
963
964 if (!headers_sent()) {
965 foreach (metasync_otto_fallback_cache_headers($ttl, $reason) as $name => $value) {
966 header($name . ': ' . $value);
967 }
968 }
969
970 # Reaches page-cache plugins, which do not see the headers above.
971 if (!defined('DONOTCACHEPAGE')) {
972 define('DONOTCACHEPAGE', true);
973 }
974 }
975
976 /**
977 * Report an OTTO render failure to the local error log and to Sentry.
978 *
979 * These failures were invisible: the render path swallowed them and returned the
980 * original HTML, so nobody knew how often they fired. Reporting them is what makes
981 * a persistently-failing URL discoverable instead of silently costing origin work.
982 *
983 * Two constraints shape this:
984 *
985 * 1. It runs on the visitor render path, so it must not add latency. The Sentry
986 * report is therefore queued here and sent by
987 * metasync_otto_flush_render_failure_reports() after the response has been
988 * handed to the visitor, so the cost is invisible rather than merely small.
989 *
990 * Two alternatives were tried and rejected. Sending inline with a blocking
991 * transport — cURL with a 5s connect and 5s read timeout — could add ten
992 * seconds to a page load, precisely when the request is already degraded.
993 * Sending inline non-blocking is far cheaper but still not free: opening the
994 * connection and writing the body costs the visitor something.
995 *
996 * A WP-Cron single event was the other obvious option and is worse than both:
997 * every scheduled event is stored in the `cron` option, which is autoloaded on
998 * every request site-wide, and WP's own 10-minute duplicate suppression keys on
999 * the serialized args — which carry per-request byte counts here, so it would
1000 * never engage. A site with many failing URLs would grow that option
1001 * unboundedly and pay for it on every page load, which is the cost the deferral
1002 * was meant to avoid in the first place.
1003 *
1004 * The local log stays synchronous. It is cheaper than Sentry but not free —
1005 * Metasync_Error_Logger::log() rewrites a summary option on each call — which is
1006 * the second reason for the throttle below.
1007 *
1008 * 2. A failing page can be requested thousands of times an hour, so reports are
1009 * throttled per reason+URL.
1010 *
1011 * @param string $reason Machine-readable failure reason (e.g. 'RENDER_EXCEPTION').
1012 * @param string $message Human-readable description.
1013 * @param array $context Extra key/value context.
1014 * @param string $level Sentry level: 'error' or 'warning'.
1015 */
1016 function metasync_otto_report_render_failure($reason, $message, $context = [], $level = 'error') {
1017 $url = '';
1018 if (!empty($_SERVER['HTTP_HOST']) && !empty($_SERVER['REQUEST_URI'])) {
1019 $url = (is_ssl() ? 'https://' : 'http://')
1020 . $_SERVER['HTTP_HOST']
1021 . strtok(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), '?');
1022 }
1023
1024 # Throttle: at most one report per reason+URL per 5 minutes. Requests without a
1025 # resolvable URL (CLI, odd SAPIs) collapse onto one key, which is intended —
1026 # they cannot be attributed to a page anyway.
1027 $throttle_key = 'metasync_otto_fail_' . md5($reason . '|' . $url);
1028 if (get_transient($throttle_key)) {
1029 return;
1030 }
1031 set_transient($throttle_key, 1, 5 * MINUTE_IN_SECONDS);
1032
1033 $context = array_merge([
1034 'reason' => $reason,
1035 'url' => $url,
1036 ], $context);
1037
1038 # Local structured log. Stays on the customer's site, so it is useful to
1039 # support but not visible to us — hence the Sentry dispatch below.
1040 if (class_exists('Metasync_Error_Logger')) {
1041 Metasync_Error_Logger::log(
1042 Metasync_Error_Logger::CATEGORY_OTTO_RENDER,
1043 $level === 'warning'
1044 ? Metasync_Error_Logger::SEVERITY_WARNING
1045 : Metasync_Error_Logger::SEVERITY_ERROR,
1046 $message,
1047 $context
1048 );
1049 }
1050
1051 # Sentry. Queued rather than sent here: even a non-blocking dispatch costs the
1052 # visitor something (opening the connection and writing the body), and this runs
1053 # on a request that is already degraded. The queue is flushed after the response
1054 # has been handed to the visitor — see metasync_otto_flush_render_failure_reports().
1055 if (!function_exists('metasync_sentry_capture_message_nonblocking')) {
1056 return;
1057 }
1058
1059 $GLOBALS['metasync_otto_pending_failure_reports'][] = [
1060 'message' => $message,
1061 'level' => $level,
1062 'context' => $context,
1063 ];
1064
1065 if (empty($GLOBALS['metasync_otto_failure_flush_hooked'])) {
1066 $GLOBALS['metasync_otto_failure_flush_hooked'] = true;
1067 # PHP_INT_MAX so this runs after everything else registered on shutdown —
1068 # WordPress flushes its own output buffers and closes the object cache at the
1069 # default priority, and this handler ends output for the request.
1070 add_action('shutdown', 'metasync_otto_flush_render_failure_reports', PHP_INT_MAX);
1071 }
1072 }
1073
1074 /**
1075 * Send any queued render-failure reports after the response has been delivered.
1076 *
1077 * fastcgi_finish_request() (PHP-FPM) and litespeed_finish_request() (LiteSpeed)
1078 * flush the response and close the connection to the visitor while leaving PHP
1079 * running. Calling one of them here means the page is already on its way before
1080 * any telemetry work starts, so the reporting cost is invisible to the visitor
1081 * rather than merely small.
1082 *
1083 * Neither exists under mod_php or the CLI SAPI. There the reports are still sent
1084 * at the very end of the request, after all page output has been generated and
1085 * flushed — the same ordering, just without the connection being closed first.
1086 *
1087 * The dispatch itself stays non-blocking. Now that the visitor is served, the only
1088 * remaining cost is PHP worker occupancy, and a blocking transport with a 5s
1089 * connect and 5s read timeout could tie up a worker for ten seconds per report on
1090 * a site that is failing often.
1091 */
1092 function metasync_otto_flush_render_failure_reports() {
1093 $reports = $GLOBALS['metasync_otto_pending_failure_reports'] ?? [];
1094 $GLOBALS['metasync_otto_pending_failure_reports'] = [];
1095
1096 if (empty($reports)) {
1097 return;
1098 }
1099
1100 if (function_exists('fastcgi_finish_request')) {
1101 fastcgi_finish_request();
1102 } elseif (function_exists('litespeed_finish_request')) {
1103 litespeed_finish_request();
1104 }
1105
1106 if (!function_exists('metasync_sentry_capture_message_nonblocking')) {
1107 return;
1108 }
1109
1110 foreach ($reports as $report) {
1111 try {
1112 metasync_sentry_capture_message_nonblocking(
1113 $report['message'],
1114 $report['level'],
1115 $report['context']
1116 );
1117 } catch (Exception $e) {
1118 # Telemetry must never be able to break a request.
1119 } catch (Error $e) {
1120 # Same.
1121 }
1122 }
1123 }
1124
1125 /**
1126 * Block SEO plugins conditionally based on what Otto is providing
1127 * Only blocks title if Otto has title, only blocks description if Otto has description
1128 * This prevents duplicate SEO tags while allowing fallback to SEO plugins when Otto has no data.
1129 * Supports Yoast SEO, Rank Math, and AIOSEO (free + pro).
1130 *
1131 * @param bool $block_title Whether to block title tags.
1132 * @param bool $block_description Whether to block description tags.
1133 * @param array $description_tags Optional. Granular list of OTTO-provided description tags
1134 * (e.g. ['meta[name=description]', 'meta[property=og:description]']).
1135 * When provided, only matching AIOSEO tags are suppressed.
1136 * When empty, all AIOSEO description tags are suppressed (legacy behavior).
1137 */
1138 function metasync_otto_block_seo_plugins($block_title = false, $block_description = false, $description_tags = []) {
1139 # Disable Yoast SEO (free and premium)
1140 if (is_plugin_active('wordpress-seo/wp-seo.php') ||
1141 is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {
1142
1143 # TITLE: Never block Yoast's title output during SSR fetch.
1144 # Yoast removes WordPress's native _wp_render_title_tag action and is the sole
1145 # renderer of the <title> tag. Returning false/empty from wpseo_title or removing
1146 # Title_Presenter leaves the page with NO <title> tag at all — OTTO's buffer
1147 # post-processing then has nothing to replace, producing a missing title.
1148 # Instead, let Yoast render its own title; OTTO's replace_title() will overwrite
1149 # it in the final HTML buffer. deduplicate_title_tags() cleans up any duplicates.
1150
1151 # Block description only if Otto has description
1152 if ($block_description) {
1153 add_filter('wpseo_metadesc', '__return_false', 999);
1154 add_filter('wpseo_meta_description', '__return_false', 999);
1155 add_filter('wpseo_metakeywords', '__return_false', 999);
1156 }
1157
1158 # Block Yoast's modern presenters — description only, never title
1159 add_filter('wpseo_frontend_presenters', function($presenters) use ($block_description) {
1160 if (!is_array($presenters)) return $presenters;
1161
1162 $presenters_to_remove = [];
1163
1164
1165 # Remove description presenters only when OTTO has a description
1166 if ($block_description) {
1167 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Meta_Description_Presenter';
1168 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Open_Graph\Description_Presenter';
1169 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Twitter\Description_Presenter';
1170 }
1171
1172 foreach ($presenters as $key => $presenter) {
1173 // Safely get class name, suppressing autoload errors
1174 // This prevents warnings when Composer autoloader tries to load deprecated Yoast files
1175 $class_name = is_object($presenter) ? @get_class($presenter) : '';
1176
1177 if (!empty($class_name) && in_array($class_name, $presenters_to_remove)) {
1178 unset($presenters[$key]);
1179 }
1180 }
1181 return $presenters;
1182 }, 999);
1183 }
1184
1185 # Disable Rank Math
1186 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
1187 is_plugin_active('seo-by-rankmath/rank-math.php')) {
1188
1189 if ($block_title) {
1190 add_filter('rank_math/frontend/title', '__return_empty_string', 999);
1191 }
1192
1193 if ($block_description) {
1194 add_filter('rank_math/frontend/description', '__return_false', 999);
1195 add_filter('rank_math/frontend/show_keywords', '__return_false', 999);
1196 }
1197 }
1198
1199 # Disable AIOSEO (free and pro)
1200 if (is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
1201 is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
1202
1203 if ($block_title) {
1204 add_filter('aioseo_title', '__return_empty_string', 999);
1205 add_filter('aioseo_facebook_tags', function($meta) {
1206 if (is_array($meta)) { unset($meta['og:title']); }
1207 return $meta;
1208 }, 999);
1209 add_filter('aioseo_twitter_tags', function($meta) {
1210 if (is_array($meta)) { unset($meta['twitter:title']); }
1211 return $meta;
1212 }, 999);
1213 }
1214
1215 if ($block_description) {
1216 # Use granular tag list when available to only block what OTTO provides
1217 $tags = !empty($description_tags) ? $description_tags : [];
1218 $block_standard = empty($tags) || in_array('meta[name=description]', $tags);
1219 $block_og_desc = empty($tags) || in_array('meta[property=og:description]', $tags);
1220 $block_tw_desc = empty($tags) || in_array('meta[name=twitter:description]', $tags);
1221
1222 if ($block_standard) {
1223 add_filter('aioseo_description', '__return_empty_string', 999);
1224 }
1225 if ($block_og_desc) {
1226 add_filter('aioseo_facebook_tags', function($meta) {
1227 if (is_array($meta)) { unset($meta['og:description']); }
1228 return $meta;
1229 }, 999);
1230 }
1231 if ($block_tw_desc) {
1232 add_filter('aioseo_twitter_tags', function($meta) {
1233 if (is_array($meta)) { unset($meta['twitter:description']); }
1234 return $meta;
1235 }, 999);
1236 }
1237 }
1238 }
1239 }
1240 # Check whether OTTO is ALSO injected via JavaScript on the site (a misconfiguration
1241 # we warn admins about).
1242 #
1243 # The actual detection makes a loopback HTTP request to the site's OWN url. On hosts
1244 # that disallow same-server loopback (e.g. SiteGround), that request blocks for the
1245 # full timeout and then fails — and when it runs inline on admin_notices it makes
1246 # every wp-admin page hang. It must therefore NEVER run inline on an admin request.
1247 #
1248 # metasync_check_otto_js() is now read-only: it returns the cached result and, on a
1249 # cache miss, schedules a one-off BACKGROUND job (WP-Cron) to compute it. The notice
1250 # simply shows nothing until the background result is available.
1251 function metasync_check_otto_js(){
1252
1253 $cache_key = 'metasync_otto_js_detected';
1254 $cached = get_transient($cache_key);
1255
1256 if ($cached !== false) {
1257 return $cached === 'yes';
1258 }
1259
1260 # No cached result yet — compute it off the request so we never block admin
1261 # with a loopback call. Show nothing this load.
1262 metasync_schedule_otto_js_check();
1263 return false;
1264 };
1265
1266 # Queue the background loopback detection if it isn't already scheduled.
1267 function metasync_schedule_otto_js_check(){
1268 if (!wp_next_scheduled('metasync_otto_js_check_event')) {
1269 wp_schedule_single_event(time() + 5, 'metasync_otto_js_check_event');
1270 }
1271 }
1272
1273 # Background worker (runs in WP-Cron context, NOT on the admin request): performs the
1274 # blocking loopback request and caches the result. Hooked to metasync_otto_js_check_event.
1275 function metasync_run_otto_js_check(){
1276 $cache_key = 'metasync_otto_js_detected';
1277
1278 # the site url with the internal-fetch marker so OTTO does not re-process it
1279 $site_url = site_url() . '?is_otto_page_fetch=1';
1280
1281 $page_data = wp_remote_get($site_url, array('timeout' => 5, 'sslverify' => false));
1282
1283 if (is_wp_error($page_data)) {
1284 # Loopback failed (host likely blocks same-server requests). Cache "no" so we
1285 # don't keep re-queuing the check on every admin page load.
1286 set_transient($cache_key, 'no', HOUR_IN_SECONDS);
1287 return;
1288 }
1289
1290 $body = wp_remote_retrieve_body($page_data);
1291
1292 # Detect the OTTO script via a lightweight regex instead of parsing
1293 # the entire fetched page into a SimpleHtmlDom tree (which exhausted memory
1294 # on large pages — this check runs on admin page loads). We only need to
1295 # confirm a <script id="sa-dynamic-optimization" ... data-uuid="..."> exists.
1296 $found = preg_match(
1297 '/<script\b[^>]*\bid=["\']sa-dynamic-optimization["\'][^>]*\bdata-uuid=["\'][^"\']+["\']/i',
1298 (string) $body
1299 )
1300 || preg_match(
1301 # Attribute order may vary (data-uuid before id)
1302 '/<script\b[^>]*\bdata-uuid=["\'][^"\']+["\'][^>]*\bid=["\']sa-dynamic-optimization["\']/i',
1303 (string) $body
1304 );
1305
1306 if ($found) {
1307 set_transient($cache_key, 'yes', 12 * HOUR_IN_SECONDS);
1308 return;
1309 }
1310
1311 set_transient($cache_key, 'no', 12 * HOUR_IN_SECONDS);
1312 }
1313
1314 # Register the background worker hook.
1315 add_action('metasync_otto_js_check_event', 'metasync_run_otto_js_check');
1316
1317 # Handle AJAX Clear Cache request
1318 # NOTE: Cache system removed - this is now a no-op
1319 function metasync_clear_otto_cache_handler() {
1320 if (!empty($_GET['clear_otto_cache'])) {
1321 delete_transient('metasync_otto_js_detected');
1322 # Re-run the JS detection in the background so the notice refreshes.
1323 metasync_schedule_otto_js_check();
1324 # Cache system has been removed - no cache to clear
1325 wp_send_json_success(['message' => 'Cache system removed - all pages processed in real-time']);
1326 }
1327 else {
1328 wp_send_json_error(['message' => 'Missing parameter']);
1329 }
1330 }
1331
1332 # Clear cache hook
1333 add_action('wp_ajax_metasync_clear_otto_cache', 'metasync_clear_otto_cache_handler');
1334
1335 # add admin action to check script
1336 function metasync_show_otto_ssr_notice() {
1337 if (!Metasync::current_user_has_plugin_access()) {
1338 return; // Only show to admins
1339 }
1340
1341 # Get the plugin name using centralized method
1342 $plugin_name = Metasync::get_effective_plugin_name();
1343 $whitelabel_otto_name = Metasync::get_whitelabel_otto_name();
1344 if (metasync_check_otto_js()) {
1345
1346 # Show admin notice with plugin name included in the message
1347 echo '<div class="notice notice-error">
1348 <p><b>Warning from ' . esc_html($plugin_name) . '</b>
1349 <br>
1350 ' . 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
1351 </p>
1352 </div>';
1353 }
1354 }
1355
1356 add_action('admin_notices', 'metasync_show_otto_ssr_notice');
1357
1358
1359 # staging dummy change
1360 # load otto in the wp hook
1361 add_action('wp', 'metasync_start_otto');
1362
1363 # ENHANCED OTTO SEO INTEGRATION
1364 # Register async SEO processing hook
1365 add_action('metasync_process_seo_job', 'metasync_process_otto_seo_data', 10, 3);
1366 add_action('metasync_process_otto_crawl_url_job', 'metasync_handle_otto_crawl_url_job', 10, 2);
1367 add_action('metasync_process_otto_batch_cache_job', 'metasync_handle_otto_batch_cache_job');
1368
1369 # Process OTTO SEO data and update WordPress meta fields for SEO plugins
1370 # This function now runs asynchronously via WordPress cron system
1371 #
1372 # @param string $route Fully-qualified URL to process.
1373 # @param bool $allow_defer When false, skip CPU-deferral rescheduling (used when
1374 # called synchronously from crawl_url_job whose own retry
1375 # mechanism already handles failures).
1376 # @param int $deferral_count How many times this job has already been deferred for
1377 # CPU load. Prevents infinite reschedule loops.
1378
1379 function metasync_process_otto_seo_data($route, $allow_defer = true, $deferral_count = 0) {
1380 # Maximum number of times a job can be deferred before it is dropped.
1381 $max_deferrals = defined('METASYNC_SEO_JOB_MAX_DEFERRALS') ? METASYNC_SEO_JOB_MAX_DEFERRALS : 5;
1382 $lock_key = null;
1383 $lock_acquired = false;
1384
1385 try {
1386 # Validate input
1387 if (empty($route) || !is_string($route)) {
1388 return false;
1389 }
1390
1391 # CPU load check — defer if server is under load.
1392 # Only reschedule when called from the cron hook (allow_defer=true) and
1393 # we haven't exceeded the maximum deferral count.
1394 # Checked BEFORE acquiring the lock so that deferred jobs don't
1395 # acquire-then-immediately-release it (which defeats concurrency protection).
1396 if (!Metasync_CPU_Monitor::is_load_safe()) {
1397 if ($allow_defer) {
1398 if ($deferral_count < $max_deferrals) {
1399 wp_schedule_single_event(
1400 time() + 60,
1401 'metasync_process_seo_job',
1402 array($route, true, $deferral_count + 1)
1403 );
1404 return false;
1405 }
1406 # deferral budget exhausted — RUN the job late instead
1407 # of dropping it. The write is idempotent and bounded, and a
1408 # late sync is strictly better than SEO data that never
1409 # arrives. No new cron events are created, so the
1410 # anti-pile-up guarantee is preserved. Fall through.
1411 } else {
1412 # allow_defer=false (sync path): return false without creating
1413 # cron events — OTTO's next crawl re-triggers this URL.
1414 return false;
1415 }
1416 }
1417
1418 # Concurrency lock: prevent multiple SEO jobs from running simultaneously.
1419 # Uses a per-URL transient lock with a 120s TTL as a safety net (the lock is
1420 # explicitly deleted on completion). If the lock exists another run is already
1421 # processing this URL — reschedule once with a short delay instead of stacking.
1422 $lock_key = 'metasync_seo_lock_' . md5($route);
1423 if (get_transient($lock_key) !== false) {
1424 if ($allow_defer && $deferral_count < $max_deferrals) {
1425 wp_schedule_single_event(
1426 time() + 30,
1427 'metasync_process_seo_job',
1428 array($route, true, $deferral_count + 1)
1429 );
1430 }
1431 return false;
1432 }
1433 set_transient($lock_key, true, 120);
1434 $lock_acquired = true;
1435
1436 # Resolve redirect table: use final destination URL before 404 checks and OTTO processing
1437 $route = metasync_otto_resolve_redirect_to_final_url($route);
1438
1439 # Skip excluded URLs - don't process SEO data for them
1440 if (metasync_is_otto_url_excluded($route)) {
1441 //error_log('MetaSync OTTO: Skipping SEO processing for excluded URL: ' . $route);
1442 return false;
1443 }
1444
1445 # Pre-flight 404 check: exclude URLs that would return 404 before making API call
1446 if (!metasync_otto_is_url_available($route)) {
1447 error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route}");
1448 metasync_otto_auto_exclude_404_url($route);
1449 return false;
1450 }
1451
1452 # Get OTTO UUID from settings
1453 # OPTIMIZED: Use cached options
1454 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
1455
1456 if (empty($otto_uuid)) {
1457 return false;
1458 }
1459
1460 # Meta descriptions are always enabled by default - no check needed
1461
1462 # Fetch SEO data from OTTO API
1463 $seo_data = metasync_fetch_otto_seo_data($route, $otto_uuid);
1464
1465 if (!$seo_data) {
1466 metasync_record_failed_action( 'metasync_process_seo_job' );
1467 return false;
1468 }
1469
1470 # Mark this URL as crawled by OTTO for SSR
1471 # Extract domain and path from route
1472 $parsed_url = parse_url($route);
1473 $domain_with_scheme = ($parsed_url['scheme'] ?? 'https') . '://' . ($parsed_url['host'] ?? '');
1474 $url_path = ($parsed_url['path'] ?? '/');
1475
1476 # Create crawl data structure
1477 $crawl_data = array(
1478 'domain' => $domain_with_scheme,
1479 'urls' => array($url_path)
1480 );
1481
1482 # Load Otto pixel class and save crawl data
1483 $otto_pixel = new Metasync_otto_pixel($otto_uuid);
1484 $otto_pixel->save_crawl_data($crawl_data);
1485
1486 # Get WordPress post ID from URL
1487 $post_id = url_to_postid($route);
1488
1489 # Special handling for WooCommerce shop page (url_to_postid doesn't work for it)
1490 if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) {
1491 # Check if this URL is the WooCommerce shop page
1492 $shop_page_id = wc_get_page_id('shop');
1493 if ($shop_page_id > 0) {
1494 $shop_url = get_permalink($shop_page_id);
1495 $route_normalized = rtrim($route, '/');
1496 $shop_url_normalized = rtrim($shop_url, '/');
1497
1498 if ($route_normalized === $shop_url_normalized) {
1499 $post_id = $shop_page_id;
1500 }
1501 }
1502 }
1503
1504 # Try to find WooCommerce product by URL if url_to_postid failed
1505 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
1506 # Extract product slug from URL
1507 $product_slug = basename(parse_url($route, PHP_URL_PATH));
1508
1509 # Try to get product by slug
1510 $products = wc_get_products(array(
1511 'name' => $product_slug,
1512 'limit' => 1,
1513 'status' => 'publish',
1514 ));
1515
1516 if (empty($products)) {
1517 # Fallback: try by slug using WP_Query
1518 $args = array(
1519 'post_type' => 'product',
1520 'name' => $product_slug,
1521 'posts_per_page' => 1,
1522 'post_status' => 'publish',
1523 );
1524 $query = new WP_Query($args);
1525
1526 if ($query->have_posts()) {
1527 $product_post = $query->posts[0];
1528 $post_id = $product_post->ID;
1529 }
1530 } else {
1531 $product = $products[0];
1532 $post_id = $product->get_id();
1533 }
1534 }
1535
1536 if (!$post_id || $post_id <= 0) {
1537 # Check if this is a category page
1538 if (strpos($route, '/category/') !== false) {
1539 # Extract category slug from URL
1540 $category_slug = basename(parse_url($route, PHP_URL_PATH));
1541 $category = get_category_by_slug($category_slug);
1542
1543 if ($category) {
1544 # Check if category would return 404 before applying OTTO changes
1545 if (metasync_would_term_return_404($category->term_id, 'category', $route)) {
1546 error_log("MetaSync OTTO: Skipping SEO processing for category that would return 404: {$route} (Category ID: {$category->term_id})");
1547 metasync_otto_auto_exclude_404_url($route);
1548 return false;
1549 }
1550
1551 # Update comprehensive category SEO meta fields
1552 $update_result = metasync_update_comprehensive_category_seo_fields($category->term_id, $seo_data);
1553
1554 if ($update_result['updated']) {
1555 # Prepare trimmed values to 30 characters
1556 $trim = function($value) {
1557 if ($value === null) { return ''; }
1558 $value = (string) $value;
1559 $value = trim($value);
1560 if (mb_strlen($value) > 30) {
1561 return mb_substr($value, 0, 30);
1562 }
1563 return $value;
1564 };
1565
1566 # Log individual field updates for category
1567 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1568 $short = '';
1569 $title = '';
1570
1571 switch ($field_type) {
1572 case 'meta_title':
1573 $short = $trim($field_value);
1574 $title = "Category Meta Title Update ({$short}...)";
1575 break;
1576 case 'meta_description':
1577 $short = $trim($field_value);
1578 $title = "Category Meta Description Update ({$short}...)";
1579 break;
1580 case 'meta_keywords':
1581 $short = $trim($field_value);
1582 $title = "Category Meta Keywords Update ({$short}...)";
1583 break;
1584 case 'og_title':
1585 $short = $trim($field_value);
1586 $title = "Category Open Graph Title Update ({$short}...)";
1587 break;
1588 case 'og_description':
1589 $short = $trim($field_value);
1590 $title = "Category Open Graph Description Update ({$short}...)";
1591 break;
1592 case 'twitter_title':
1593 $short = $trim($field_value);
1594 $title = "Category Twitter Title Update ({$short}...)";
1595 break;
1596 case 'twitter_description':
1597 $short = $trim($field_value);
1598 $title = "Category Twitter Description Update ({$short}...)";
1599 break;
1600 case 'image_alt_data':
1601 $image_count = count($field_value);
1602 $title = "Category Image Alt Text Update ({$image_count} images)";
1603 break;
1604 case 'headings_data':
1605 $heading_count = count($field_value);
1606 $title = "Category Headings Update ({$heading_count} headings)";
1607 break;
1608 case 'structured_data':
1609 $title = 'Category Structured Data Update';
1610 break;
1611 }
1612
1613 if (!empty($title)) {
1614 metasync_log_sync_history([
1615 'title' => $title,
1616 'source' => 'OTTO SEO',
1617 'status' => 'published',
1618 'content_type' => 'Category SEO',
1619 'url' => $route,
1620 'meta_data' => json_encode([
1621 'field' => $field_type,
1622 'field_value' => $field_value,
1623 'category_id' => $category->term_id,
1624 'category_name' => $category->name
1625 ])
1626 ]);
1627 }
1628 }
1629
1630 return true;
1631 }
1632
1633 return false;
1634 }
1635 }
1636
1637 # Check if this is a WooCommerce product category
1638 if (strpos($route, '/product-category/') !== false) {
1639 # Extract product category slug from URL
1640 $category_slug = basename(parse_url($route, PHP_URL_PATH));
1641 $term = get_term_by('slug', $category_slug, 'product_cat');
1642
1643 if ($term && !is_wp_error($term)) {
1644 # Check if product category would return 404 before applying OTTO changes
1645 if (metasync_would_term_return_404($term->term_id, 'product_cat', $route)) {
1646 error_log("MetaSync OTTO: Skipping SEO processing for product category that would return 404: {$route} (Term ID: {$term->term_id})");
1647 metasync_otto_auto_exclude_404_url($route);
1648 return false;
1649 }
1650
1651 # Update comprehensive taxonomy SEO meta fields
1652 $update_result = metasync_update_comprehensive_taxonomy_seo_fields($term->term_id, 'product_cat', $seo_data);
1653
1654 if ($update_result['updated']) {
1655 # Prepare trimmed values to 30 characters
1656 $trim = function($value) {
1657 if ($value === null) { return ''; }
1658 $value = (string) $value;
1659 $value = trim($value);
1660 if (mb_strlen($value) > 30) {
1661 return mb_substr($value, 0, 30);
1662 }
1663 return $value;
1664 };
1665
1666 # Log individual field updates for product category
1667 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1668 $short = '';
1669 $title = '';
1670
1671 switch ($field_type) {
1672 case 'meta_title':
1673 $short = $trim($field_value);
1674 $title = "Product Category Meta Title Update ({$short}...)";
1675 break;
1676 case 'meta_description':
1677 $short = $trim($field_value);
1678 $title = "Product Category Meta Description Update ({$short}...)";
1679 break;
1680 case 'meta_keywords':
1681 $short = $trim($field_value);
1682 $title = "Product Category Meta Keywords Update ({$short}...)";
1683 break;
1684 case 'og_title':
1685 $short = $trim($field_value);
1686 $title = "Product Category Open Graph Title Update ({$short}...)";
1687 break;
1688 case 'og_description':
1689 $short = $trim($field_value);
1690 $title = "Product Category Open Graph Description Update ({$short}...)";
1691 break;
1692 case 'twitter_title':
1693 $short = $trim($field_value);
1694 $title = "Product Category Twitter Title Update ({$short}...)";
1695 break;
1696 case 'twitter_description':
1697 $short = $trim($field_value);
1698 $title = "Product Category Twitter Description Update ({$short}...)";
1699 break;
1700 case 'image_alt_data':
1701 $image_count = count($field_value);
1702 $title = "Product Category Image Alt Text Update ({$image_count} images)";
1703 break;
1704 case 'headings_data':
1705 $heading_count = count($field_value);
1706 $title = "Product Category Headings Update ({$heading_count} headings)";
1707 break;
1708 case 'structured_data':
1709 $title = 'Product Category Structured Data Update';
1710 break;
1711 }
1712
1713 if (!empty($title)) {
1714 metasync_log_sync_history([
1715 'title' => $title,
1716 'source' => 'OTTO SEO',
1717 'status' => 'published',
1718 'content_type' => 'WooCommerce Product Category SEO',
1719 'url' => $route,
1720 'meta_data' => json_encode([
1721 'field' => $field_type,
1722 'field_value' => $field_value,
1723 'term_id' => $term->term_id,
1724 'term_name' => $term->name,
1725 'taxonomy' => 'product_cat'
1726 ])
1727 ]);
1728 }
1729 }
1730
1731 return true;
1732 }
1733
1734 return false;
1735 }
1736 }
1737
1738 # Check if this is the home page (landing page)
1739 $site_url = rtrim(site_url(), '/');
1740 $route_clean = rtrim($route, '/');
1741
1742 if ($route_clean === $site_url) {
1743 # Get the home page (front page)
1744 $front_page_id = get_option('page_on_front');
1745 $home_page = null;
1746
1747 if ($front_page_id && $front_page_id > 0) {
1748 $home_page = get_post($front_page_id);
1749 } else {
1750 # If no static front page is set, get the latest post
1751 $home_page = get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null;
1752 }
1753
1754 if ($home_page) {
1755 # Check if home page would return 404 before applying OTTO changes
1756 if (metasync_would_page_return_404($home_page->ID, $route)) {
1757 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})");
1758 metasync_otto_auto_exclude_404_url($route);
1759 return false;
1760 }
1761
1762 # Update comprehensive home page SEO meta fields
1763 $update_result = metasync_update_comprehensive_seo_fields($home_page->ID, $seo_data);
1764
1765 if ($update_result['updated']) {
1766 # Clear relevant caches
1767 metasync_clear_post_seo_caches($home_page->ID);
1768
1769 # Prepare trimmed values to 30 characters
1770 $trim = function($value) {
1771 if ($value === null) { return ''; }
1772 $value = (string) $value;
1773 $value = trim($value);
1774 if (mb_strlen($value) > 30) {
1775 return mb_substr($value, 0, 30);
1776 }
1777 return $value;
1778 };
1779
1780 # Log individual field updates for home page
1781 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1782 $short = '';
1783 $title = '';
1784
1785 switch ($field_type) {
1786 case 'meta_title':
1787 $short = $trim($field_value);
1788 $title = "Home Page Meta Title Update ({$short}...)";
1789 break;
1790 case 'meta_description':
1791 $short = $trim($field_value);
1792 $title = "Home Page Meta Description Update ({$short}...)";
1793 break;
1794 case 'meta_keywords':
1795 $short = $trim($field_value);
1796 $title = "Home Page Meta Keywords Update ({$short}...)";
1797 break;
1798 case 'og_title':
1799 $short = $trim($field_value);
1800 $title = "Home Page Open Graph Title Update ({$short}...)";
1801 break;
1802 case 'og_description':
1803 $short = $trim($field_value);
1804 $title = "Home Page Open Graph Description Update ({$short}...)";
1805 break;
1806 case 'twitter_title':
1807 $short = $trim($field_value);
1808 $title = "Home Page Twitter Title Update ({$short}...)";
1809 break;
1810 case 'twitter_description':
1811 $short = $trim($field_value);
1812 $title = "Home Page Twitter Description Update ({$short}...)";
1813 break;
1814 case 'image_alt_data':
1815 $image_count = count($field_value);
1816 $title = "Home Page Image Alt Text Update ({$image_count} images)";
1817 break;
1818 case 'headings_data':
1819 $heading_count = count($field_value);
1820 $title = "Home Page Headings Update ({$heading_count} headings)";
1821 break;
1822 case 'structured_data':
1823 $title = 'Home Page Structured Data Update';
1824 break;
1825 }
1826
1827 if (!empty($title)) {
1828 metasync_log_sync_history([
1829 'title' => $title,
1830 'source' => 'OTTO SEO',
1831 'status' => 'published',
1832 'content_type' => 'Home Page SEO',
1833 'url' => $route,
1834 'meta_data' => json_encode([
1835 'field' => $field_type,
1836 'field_value' => $field_value,
1837 'post_id' => $home_page->ID
1838 ])
1839 ]);
1840 }
1841 }
1842
1843 return true;
1844 }
1845
1846 return false;
1847 }
1848 }
1849
1850 # Check if this is the blog/posts page (page_for_posts)
1851 $posts_page_id = intval(get_option('page_for_posts'));
1852 if ($posts_page_id > 0) {
1853 $posts_page = get_post($posts_page_id);
1854 $posts_page_url = rtrim(get_permalink($posts_page_id), '/');
1855
1856 if ($posts_page && $route_clean === $posts_page_url) {
1857 # Check if blog page would return 404
1858 if (metasync_would_page_return_404($posts_page->ID, $route)) {
1859 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})");
1860 metasync_otto_auto_exclude_404_url($route);
1861 return false;
1862 }
1863
1864 # Update comprehensive blog page SEO meta fields
1865 $update_result = metasync_update_comprehensive_seo_fields($posts_page->ID, $seo_data);
1866
1867 if ($update_result['updated']) {
1868 # Clear relevant caches
1869 metasync_clear_post_seo_caches($posts_page->ID);
1870
1871 # Prepare trimmed values to 30 characters
1872 $trim = function($value) {
1873 if ($value === null) { return ''; }
1874 $value = (string) $value;
1875 $value = trim($value);
1876 if (mb_strlen($value) > 30) {
1877 return mb_substr($value, 0, 30);
1878 }
1879 return $value;
1880 };
1881
1882 # Log individual field updates for blog page
1883 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1884 $short = '';
1885 $title = '';
1886
1887 switch ($field_type) {
1888 case 'meta_title':
1889 $short = $trim($field_value);
1890 $title = "Blog Page Meta Title Update ({$short}...)";
1891 break;
1892 case 'meta_description':
1893 $short = $trim($field_value);
1894 $title = "Blog Page Meta Description Update ({$short}...)";
1895 break;
1896 case 'meta_keywords':
1897 $short = $trim($field_value);
1898 $title = "Blog Page Meta Keywords Update ({$short}...)";
1899 break;
1900 case 'og_title':
1901 $short = $trim($field_value);
1902 $title = "Blog Page Open Graph Title Update ({$short}...)";
1903 break;
1904 case 'og_description':
1905 $short = $trim($field_value);
1906 $title = "Blog Page Open Graph Description Update ({$short}...)";
1907 break;
1908 case 'twitter_title':
1909 $short = $trim($field_value);
1910 $title = "Blog Page Twitter Title Update ({$short}...)";
1911 break;
1912 case 'twitter_description':
1913 $short = $trim($field_value);
1914 $title = "Blog Page Twitter Description Update ({$short}...)";
1915 break;
1916 case 'image_alt_data':
1917 $image_count = count($field_value);
1918 $title = "Blog Page Image Alt Text Update ({$image_count} images)";
1919 break;
1920 case 'headings_data':
1921 $heading_count = count($field_value);
1922 $title = "Blog Page Headings Update ({$heading_count} headings)";
1923 break;
1924 case 'structured_data':
1925 $title = 'Blog Page Structured Data Update';
1926 break;
1927 }
1928
1929 if (!empty($title)) {
1930 metasync_log_sync_history([
1931 'title' => $title,
1932 'source' => 'OTTO SEO',
1933 'status' => 'published',
1934 'content_type' => 'Blog Page SEO',
1935 'url' => $route,
1936 'meta_data' => json_encode([
1937 'field' => $field_type,
1938 'field_value' => $field_value,
1939 'post_id' => $posts_page->ID
1940 ])
1941 ]);
1942 }
1943 }
1944
1945 return true;
1946 }
1947
1948 return false;
1949 }
1950 }
1951
1952 # URL didn't resolve to any supported entity (post, category, home page, blog page)
1953 # Treat as 404 and auto-exclude (e.g. deleted post, non-existent page)
1954 if (!metasync_otto_is_url_available($route)) {
1955 error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404 (no matching entity): {$route}");
1956 metasync_otto_auto_exclude_404_url($route);
1957 }
1958 return false;
1959 }
1960
1961 # Verify this is actually a post, page, or WooCommerce product
1962 $post = get_post($post_id);
1963
1964 # Get supported post types dynamically
1965 $supported_post_types = metasync_get_supported_post_types();
1966
1967 if (!$post || !in_array($post->post_type, $supported_post_types)) {
1968 # Skip unsupported post types
1969 return false;
1970 }
1971
1972 # Check if page would return 404 before applying OTTO changes
1973 if (metasync_would_page_return_404($post_id, $route)) {
1974 error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route} (Post ID: {$post_id}, Status: {$post->post_status})");
1975 metasync_otto_auto_exclude_404_url($route);
1976 return false;
1977 }
1978
1979 # Update comprehensive SEO meta fields
1980 $update_result = metasync_update_comprehensive_seo_fields($post_id, $seo_data);
1981
1982 if ($update_result['updated']) {
1983 # Clear relevant caches
1984 metasync_clear_post_seo_caches($post_id);
1985
1986 # Prepare trimmed values to 30 characters
1987 $trim = function($value) {
1988 if ($value === null) { return ''; }
1989 $value = (string) $value;
1990 $value = trim($value);
1991 if (mb_strlen($value) > 30) {
1992 return mb_substr($value, 0, 30);
1993 }
1994 return $value;
1995 };
1996
1997 # Log individual field updates
1998 foreach ($update_result['fields_updated'] as $field_type => $field_value) {
1999 $short = '';
2000 $title = '';
2001
2002 switch ($field_type) {
2003 case 'meta_title':
2004 $short = $trim($field_value);
2005 $title = 'Meta Title Update (' . $short . '...)';
2006 break;
2007 case 'meta_description':
2008 $short = $trim($field_value);
2009 $title = 'Meta Description Update (' . $short . '...)';
2010 break;
2011 case 'meta_keywords':
2012 $short = $trim($field_value);
2013 $title = 'Meta Keywords Update (' . $short . '...)';
2014 break;
2015 case 'og_title':
2016 $short = $trim($field_value);
2017 $title = 'Open Graph Title Update (' . $short . '...)';
2018 break;
2019 case 'og_description':
2020 $short = $trim($field_value);
2021 $title = 'Open Graph Description Update (' . $short . '...)';
2022 break;
2023 case 'twitter_title':
2024 $short = $trim($field_value);
2025 $title = 'Twitter Title Update (' . $short . '...)';
2026 break;
2027 case 'twitter_description':
2028 $short = $trim($field_value);
2029 $title = 'Twitter Description Update (' . $short . '...)';
2030 break;
2031 case 'image_alt_data':
2032 $image_count = count($field_value);
2033 $title = "Image Alt Text Update ({$image_count} images)";
2034 break;
2035 case 'headings_data':
2036 $heading_count = count($field_value);
2037 $title = "Headings Update ({$heading_count} headings)";
2038 break;
2039 case 'structured_data':
2040 $title = 'Structured Data Update';
2041 break;
2042 }
2043
2044 if (!empty($title)) {
2045 metasync_log_sync_history([
2046 'title' => $title,
2047 'source' => 'OTTO SEO',
2048 'status' => 'published',
2049 'content_type' => 'SEO Meta',
2050 'url' => $route,
2051 'meta_data' => json_encode([
2052 'field' => $field_type,
2053 'field_value' => $field_value,
2054 'post_id' => $post_id
2055 ])
2056 ]);
2057 }
2058 }
2059
2060 return true;
2061 }
2062
2063 return false;
2064
2065 } catch (Exception $e) {
2066 metasync_record_failed_action( 'metasync_process_seo_job' );
2067 return false;
2068 } finally {
2069 # Release the concurrency lock only if we actually acquired it.
2070 # Early returns (CPU deferral, lock contention) must NOT delete a lock
2071 # that another process may be holding.
2072 if ($lock_acquired && $lock_key) {
2073 delete_transient($lock_key);
2074 }
2075 }
2076 }
2077
2078 /**
2079 * Log sync history entry
2080 * @param array $data Sync data to log
2081 */
2082 function metasync_log_sync_history($data) {
2083 try {
2084 // Classes are now autoloaded, no need for manual require
2085 $sync_db = new Metasync_Sync_History_Database();
2086
2087 // Minimal duplicate prevention within short time window
2088 if (!empty($data['title']) && !empty($data['source'])) {
2089 global $wpdb;
2090 $table = $wpdb->prefix . Metasync_Sync_History_Database::$table_name;
2091 $recent = $wpdb->get_var($wpdb->prepare(
2092 "SELECT COUNT(*) FROM `$table` WHERE title = %s AND source = %s AND created_at >= %s",
2093 $data['title'],
2094 $data['source'],
2095 gmdate('Y-m-d H:i:s', time() - 60)
2096 ));
2097 if ((int)$recent > 0) {
2098 return; // skip duplicate log within 60 seconds
2099 }
2100 }
2101
2102 $sync_db->add($data);
2103
2104 } catch (Exception $e) {
2105 error_log("MetaSync: Failed to log sync history: " . $e->getMessage());
2106 }
2107 }
2108
2109 /**
2110 * Get supported post types for OTTO SEO optimization
2111 * Includes WooCommerce products if WooCommerce is active
2112 *
2113 * @return array List of supported post types
2114 */
2115 function metasync_get_supported_post_types() {
2116 # Start with default post types
2117 $post_types = ['post', 'page'];
2118
2119 # Add WooCommerce product post type if WooCommerce is active
2120 if (class_exists('WooCommerce') || function_exists('is_woocommerce')) {
2121 $post_types[] = 'product';
2122 }
2123
2124 # Include all public custom post types (e.g. 'location', 'service', 'team', etc.)
2125 # so OTTO can write post meta for them during metasync_process_otto_seo_data().
2126 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
2127 if (!empty($custom_post_types)) {
2128 $post_types = array_merge($post_types, array_values($custom_post_types));
2129 }
2130
2131 # Allow developers to filter supported post types
2132 $post_types = apply_filters('metasync_otto_supported_post_types', $post_types);
2133
2134 return $post_types;
2135 }
2136
2137 /**
2138 * Get supported taxonomies for OTTO SEO optimization
2139 * Includes WooCommerce product categories and tags if WooCommerce is active
2140 *
2141 * @return array List of supported taxonomies
2142 */
2143 function metasync_get_supported_taxonomies() {
2144 # Start with default taxonomies
2145 $taxonomies = ['category'];
2146
2147 # Add WooCommerce taxonomies if WooCommerce is active
2148 if (class_exists('WooCommerce') || function_exists('is_woocommerce')) {
2149 $taxonomies[] = 'product_cat'; # WooCommerce product categories
2150 $taxonomies[] = 'product_tag'; # WooCommerce product tags
2151 }
2152
2153 # Allow developers to filter supported taxonomies
2154 $taxonomies = apply_filters('metasync_otto_supported_taxonomies', $taxonomies);
2155
2156 return $taxonomies;
2157 }
2158
2159 /**
2160 * Resolve a URL through the Redirect Manager table to its final destination (follows redirect chains).
2161 * Used before 404 checks and OTTO processing so the final canonical URL is used, not intermediate redirects.
2162 *
2163 * @param string $url Full URL (e.g. https://example.com/old-page)
2164 * @return string Final destination URL, or original $url if no redirect matches
2165 */
2166 function metasync_otto_resolve_redirect_to_final_url($url)
2167 {
2168 if (empty($url) || !is_string($url)) {
2169 return $url;
2170 }
2171 try {
2172 $db_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection-database.php';
2173 $class_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection.php';
2174 if (!file_exists($db_path) || !file_exists($class_path)) {
2175 return $url;
2176 }
2177 require_once $db_path;
2178 require_once $class_path;
2179 $db = new Metasync_Redirection_Database();
2180 $redirect = new Metasync_Redirection($db);
2181 return $redirect->resolve_url_to_final_destination($url, 10);
2182 } catch (Exception $e) {
2183 error_log('MetaSync OTTO: Redirect resolution failed for ' . $url . ' - ' . $e->getMessage());
2184 return $url;
2185 }
2186 }
2187
2188 /**
2189 * Auto-exclude a URL from OTTO with description "Auto-excluded: 404"
2190 * Called when a URL is detected as returning 404 so it won't be sent to OTTO again
2191 *
2192 * @param string $url Full URL to exclude (e.g. https://example.com/404-page)
2193 * @return bool|string True on success, false on failure, 'duplicate'/'reactivated' if already exists
2194 */
2195 function metasync_otto_auto_exclude_404_url($url)
2196 {
2197 if (empty($url) || !is_string($url)) {
2198 return false;
2199 }
2200 $url = filter_var($url, FILTER_SANITIZE_URL);
2201 $url = esc_url_raw($url);
2202 if (empty($url) || mb_strlen($url) > 2048) {
2203 return false;
2204 }
2205 try {
2206 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2207 $db = new Metasync_Otto_Excluded_URLs_Database();
2208 return $db->add([
2209 'url_pattern' => $url,
2210 'pattern_type' => 'exact',
2211 'description' => 'Auto-excluded: 404',
2212 'status' => 'active',
2213 'auto_excluded' => 1,
2214 ]);
2215 } catch (Exception $e) {
2216 error_log('MetaSync OTTO: Failed to auto-exclude 404 URL: ' . $url . ' - ' . $e->getMessage());
2217 return false;
2218 }
2219 }
2220
2221 /**
2222 * Remove a URL from the OTTO auto-exclusion list.
2223 * Called when OTTO sends a webhook for a URL, confirming it is valid and crawlable.
2224 * Only removes records where auto_excluded = 1 (never removes manual exclusions).
2225 *
2226 * @param string $url Full URL to un-exclude (e.g. https://example.com/location/page)
2227 * @return bool True on success
2228 */
2229 function metasync_otto_remove_auto_exclusion($url)
2230 {
2231 if (empty($url) || !is_string($url)) {
2232 return false;
2233 }
2234 try {
2235 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2236 $db = new Metasync_Otto_Excluded_URLs_Database();
2237 global $wpdb;
2238 $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
2239 # Normalize the same way is_url_excluded() does
2240 $url_normalized = rtrim(trim($url), '/');
2241 $records = $wpdb->get_results(
2242 $wpdb->prepare(
2243 "SELECT id FROM `{$table}` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'",
2244 $url_normalized
2245 )
2246 );
2247 if (!empty($records)) {
2248 $ids = array_map(function ($r) { return (int) $r->id; }, $records);
2249 $db->delete($ids);
2250 }
2251 return true;
2252 } catch (Exception $e) {
2253 return false;
2254 }
2255 }
2256
2257 /**
2258 * Check if a URL is MANUALLY excluded from OTTO (auto_excluded = 0).
2259 * Used at render time (metasync_start_otto) — auto-exclusions must NOT block
2260 * rendering because they are often false positives (e.g. custom post types that
2261 * url_to_postid() can't resolve). Auto-exclusions are only used to gate the
2262 * SEO meta-writing webhook path.
2263 *
2264 * @param string $url URL to check
2265 * @return bool True if URL has a manual exclusion
2266 */
2267 function metasync_is_otto_url_manually_excluded($url)
2268 {
2269 if (empty($url) || !is_string($url)) {
2270 return false;
2271 }
2272 try {
2273 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2274 global $wpdb;
2275 $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
2276 $url_normalized = rtrim(trim($url), '/');
2277
2278 $records = get_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY);
2279
2280 if ($records === false) {
2281 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- no user input, table name from $wpdb->prefix
2282 $records = $wpdb->get_results(
2283 "SELECT url_pattern, pattern_type FROM `{$table}` WHERE status = 'active' AND (auto_excluded = 0 OR auto_excluded IS NULL) ORDER BY created_at DESC"
2284 );
2285
2286 // Graceful recovery: auto_excluded column missing on pre-v2.7.4 installs.
2287 // Run ALTER TABLE to add it and treat URL as not excluded so OTTO continues rendering.
2288 if ($wpdb->last_error && strpos($wpdb->last_error, 'auto_excluded') !== false) {
2289 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
2290 $wpdb->query("ALTER TABLE `{$table}` ADD COLUMN `auto_excluded` TINYINT(1) NOT NULL DEFAULT 0");
2291 return false;
2292 }
2293
2294 set_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY, $records ?: [], METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL);
2295 }
2296
2297 if (empty($records)) {
2298 return false;
2299 }
2300
2301 foreach ($records as $excluded) {
2302 $pattern = rtrim(trim($excluded->url_pattern), '/');
2303 $pattern_type = $excluded->pattern_type;
2304
2305 switch ($pattern_type) {
2306 case 'exact':
2307 if ($url_normalized === $pattern) {
2308 return true;
2309 }
2310 break;
2311 case 'contain':
2312 if (strpos($url_normalized, $pattern) !== false) {
2313 return true;
2314 }
2315 break;
2316 case 'start':
2317 if (strpos($url_normalized, $pattern) === 0) {
2318 return true;
2319 }
2320 break;
2321 }
2322 }
2323 return false;
2324 } catch (Exception $e) {
2325 return false;
2326 }
2327 }
2328
2329 /**
2330 * Check if a URL is excluded from OTTO
2331 * @param string $url URL to check
2332 * @return bool True if URL is excluded, false otherwise
2333 */
2334 function metasync_is_otto_url_excluded($url)
2335 {
2336 try {
2337 // Load database class
2338 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2339 $db = new Metasync_Otto_Excluded_URLs_Database();
2340
2341 // Check if URL is excluded
2342 return $db->is_url_excluded($url);
2343
2344 } catch (Exception $e) {
2345 return false;
2346 }
2347 }
2348
2349 /**
2350 * Check if a URL is now available (would NOT return 404)
2351 * Uses same resolution logic as metasync_process_otto_seo_data
2352 * Used when rechecking auto-excluded 404 URLs after 7 days
2353 *
2354 * @param string $url Full URL to check (e.g. https://example.com/page)
2355 * @return bool True if URL is accessible, false if it would return 404
2356 */
2357 function metasync_otto_is_url_available($url)
2358 {
2359 if (empty($url) || !is_string($url)) {
2360 return false;
2361 }
2362
2363 $route = metasync_otto_resolve_redirect_to_final_url($url);
2364 $post_id = url_to_postid($route);
2365
2366 # WooCommerce shop page
2367 if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) {
2368 $shop_page_id = wc_get_page_id('shop');
2369 if ($shop_page_id > 0) {
2370 $shop_url = get_permalink($shop_page_id);
2371 if (rtrim($route, '/') === rtrim($shop_url, '/')) {
2372 $post_id = $shop_page_id;
2373 }
2374 }
2375 }
2376
2377 # WooCommerce product by slug
2378 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
2379 $product_slug = basename(parse_url($route, PHP_URL_PATH));
2380 $products = wc_get_products(array('name' => $product_slug, 'limit' => 1, 'status' => 'publish'));
2381 if (!empty($products)) {
2382 $post_id = $products[0]->get_id();
2383 } else {
2384 $query = new WP_Query(array(
2385 'post_type' => 'product',
2386 'name' => $product_slug,
2387 'posts_per_page' => 1,
2388 'post_status' => 'publish',
2389 ));
2390 if ($query->have_posts()) {
2391 $post_id = $query->posts[0]->ID;
2392 }
2393 }
2394 }
2395
2396 if ($post_id && $post_id > 0) {
2397 $post = get_post($post_id);
2398 # Accept any post type — custom post types (e.g. 'location', 'service') are valid URLs.
2399 # The old in_array check against metasync_get_supported_post_types() caused CPT URLs
2400 # to be wrongly auto-excluded as "404" pages.
2401 if ($post) {
2402 return !metasync_would_page_return_404($post_id, $route);
2403 }
2404 }
2405
2406 # Category
2407 if (strpos($route, '/category/') !== false) {
2408 $category_slug = basename(parse_url($route, PHP_URL_PATH));
2409 $category = get_category_by_slug($category_slug);
2410 if ($category) {
2411 return !metasync_would_term_return_404($category->term_id, 'category', $route);
2412 }
2413 }
2414
2415 # WooCommerce product category
2416 if (strpos($route, '/product-category/') !== false) {
2417 $category_slug = basename(parse_url($route, PHP_URL_PATH));
2418 $term = get_term_by('slug', $category_slug, 'product_cat');
2419 if ($term && !is_wp_error($term)) {
2420 return !metasync_would_term_return_404($term->term_id, 'product_cat', $route);
2421 }
2422 }
2423
2424 # Home page
2425 if (rtrim($route, '/') === rtrim(site_url(), '/')) {
2426 $front_page_id = get_option('page_on_front');
2427 $home_page = ($front_page_id && $front_page_id > 0)
2428 ? get_post($front_page_id)
2429 : (get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null);
2430 if ($home_page) {
2431 return !metasync_would_page_return_404($home_page->ID, $route);
2432 }
2433 }
2434
2435 # Could not verify availability from local data (custom archive, paginated page, etc.).
2436 # Assume the URL IS available — OTTO only crawls reachable URLs, so if we can't
2437 # prove it's a 404, we should not auto-exclude it.
2438 return true;
2439 }
2440
2441 /**
2442 * Recheck auto-excluded 404 URLs when recheck_after has passed; remove from exclusion if now available
2443 * Uses recheck_after timestamp (default 7 days from exclusion) to decide when to recheck
2444 * Mark as permanent after 30 days if still 404 (no further rechecks)
2445 * Called by daily cron job
2446 */
2447 function metasync_otto_recheck_404_exclusions()
2448 {
2449 try {
2450 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2451 $db = new Metasync_Otto_Excluded_URLs_Database();
2452 $records = $db->get_auto_excluded_404_urls_due_for_recheck();
2453
2454 if (empty($records)) {
2455 return;
2456 }
2457
2458 $removed = 0;
2459 $marked_permanent = 0;
2460 $thirty_days_ago = strtotime('-30 days');
2461 $next_recheck = date('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS);
2462
2463 foreach ($records as $record) {
2464 $url = trim($record->url_pattern);
2465 if (empty($url)) {
2466 continue;
2467 }
2468 if (metasync_otto_is_url_available($url)) {
2469 $db->delete([$record->id]);
2470 $removed++;
2471 } else {
2472 # Still 404: if excluded 30+ days ago, mark as permanent (no more rechecks)
2473 $created_ts = strtotime($record->created_at);
2474 if ($created_ts <= $thirty_days_ago) {
2475 $db->update(['is_permanent' => 1], $record->id);
2476 $marked_permanent++;
2477 } else {
2478 # Schedule next recheck in 7 days
2479 $db->update(['recheck_after' => $next_recheck], $record->id);
2480 }
2481 }
2482 }
2483
2484 if ($removed > 0) {
2485 error_log("MetaSync OTTO: Recheck 404 exclusions - removed {$removed} URL(s) that are now available");
2486 }
2487 if ($marked_permanent > 0) {
2488 error_log("MetaSync OTTO: Recheck 404 exclusions - marked {$marked_permanent} URL(s) as permanent (still 404 after 30 days)");
2489 }
2490 } catch (Exception $e) {
2491 error_log('MetaSync OTTO: Recheck 404 exclusions failed - ' . $e->getMessage());
2492 }
2493 }
2494
2495 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
2496
2497 /**
2498 * Check if a post/page would return 404 without making HTTP request
2499 * Uses WordPress database checks for fast validation
2500 *
2501 * @param int $post_id WordPress post ID
2502 * @param string $url The URL being checked (optional, for logging)
2503 * @return bool True if page would return 404, false if accessible
2504 */
2505 function metasync_would_page_return_404($post_id, $url = '') {
2506 if (!$post_id || $post_id <= 0) {
2507 return true; // No post ID = 404
2508 }
2509
2510 # Get the post object
2511 $post = get_post($post_id);
2512 if (!$post) {
2513 return true; // Post doesn't exist = 404
2514 }
2515
2516 # 1. Check post status - must be 'publish' to be publicly accessible
2517 if ($post->post_status !== 'publish') {
2518 return true; // Draft, pending, private, etc. = 404
2519 }
2520
2521 # 2. Check if post is password protected (requires password to view)
2522 if (!empty($post->post_password)) {
2523 # Password protected posts are not publicly accessible without password
2524 return true; // Password protected = effectively 404 for public
2525 }
2526
2527 # 3. Check if post is in trash
2528 if ($post->post_status === 'trash') {
2529 return true; // Trashed = 404
2530 }
2531
2532 # 4. Check if post type is publicly queryable
2533 $post_type_object = get_post_type_object($post->post_type);
2534 if ($post_type_object && !$post_type_object->publicly_queryable) {
2535 # Some post types might not be publicly accessible
2536 # But we allow if it's in our supported types
2537 $supported_post_types = metasync_get_supported_post_types();
2538 if (!in_array($post->post_type, $supported_post_types)) {
2539 return true; // Not publicly queryable = 404
2540 }
2541 }
2542
2543 # 5. WordPress 5.7+ has a built-in function for this
2544 if (function_exists('is_post_publicly_viewable')) {
2545 if (!is_post_publicly_viewable($post)) {
2546 return true; // Not publicly viewable = 404
2547 }
2548 }
2549
2550 # 6. Check if post is scheduled for future (not yet published)
2551 if ($post->post_date > current_time('mysql')) {
2552 return true; // Future post = 404 until publish date
2553 }
2554
2555 # All checks passed - page should be accessible
2556 return false;
2557 }
2558
2559 /**
2560 * Check if a taxonomy term (category, tag, etc.) would return 404
2561 * Uses WordPress database checks for fast validation
2562 *
2563 * @param int $term_id Term ID
2564 * @param string $taxonomy Taxonomy name (e.g., 'category', 'product_cat')
2565 * @param string $url The URL being checked (optional, for logging)
2566 * @return bool True if term would return 404, false if accessible
2567 */
2568 function metasync_would_term_return_404($term_id, $taxonomy, $url = '') {
2569 if (!$term_id || $term_id <= 0 || empty($taxonomy)) {
2570 return true; // Invalid term = 404
2571 }
2572
2573 # Get the term object
2574 $term = get_term($term_id, $taxonomy);
2575 if (is_wp_error($term) || !$term) {
2576 return true; // Term doesn't exist = 404
2577 }
2578
2579 # Check if taxonomy is publicly queryable
2580 $taxonomy_object = get_taxonomy($taxonomy);
2581 if (!$taxonomy_object || !$taxonomy_object->public) {
2582 # Check if it's in our supported taxonomies
2583 $supported_taxonomies = metasync_get_supported_taxonomies();
2584 if (!in_array($taxonomy, $supported_taxonomies)) {
2585 return true; // Not publicly queryable = 404
2586 }
2587 }
2588
2589 # Terms are generally always accessible if they exist and taxonomy is public
2590 # WordPress doesn't have a "draft" status for terms like posts do
2591 # But we can check if the term has a count (has posts assigned)
2592 # Empty terms might not be useful, but they're still accessible
2593
2594 # All checks passed - term should be accessible
2595 return false;
2596 }
2597
2598 /**
2599 * Invalidate Brizy posts cache when posts are saved
2600 * OPTIMIZATION: Clears transient cache to ensure accurate detection
2601 */
2602 add_action('save_post', function($post_id) {
2603 # Check if this post has Brizy metadata
2604 if (get_post_meta($post_id, 'brizy_post_uid', true)) {
2605 delete_transient('metasync_has_brizy_posts');
2606 }
2607 }, 10, 1);
2608
2609 /**
2610 * Identify infrastructure agents that must never hit the render throttle.
2611 *
2612 * Two classes of agent are covered:
2613 *
2614 * - Page-cache warmers. A host preloader (WP Cloud PageCacheBot, WP Rocket preload,
2615 * LiteSpeed, SG Optimizer, ...) exists purely to populate the page cache. Throttling
2616 * one returns un-OTTO'd HTML *and* defines DONOTCACHEPAGE, so the cache can never be
2617 * filled and every subsequent human visitor pays a full uncached render. Measured on
2618 * the reporting site: desktop cache MISS on 17/17 requests at ~2.8s TTFB, versus
2619 * ~0.56s with OTTO disabled.
2620 *
2621 * - Synthetic performance auditors (Lighthouse / PageSpeed Insights / GTmetrix).
2622 * These are what customers measure with, so they must receive the same fully
2623 * rendered OTTO output a real visitor gets. Currently `/lighthouse/i` and
2624 * `/pagespeed/i` are generic-bot patterns, so a second PSI run inside the 5-minute
2625 * window is served the throttled, un-OTTO'd, uncacheable page.
2626 *
2627 * Matching is done on the raw user-agent, deliberately independent of the bot-name
2628 * categorisation in Metasync_Otto_Bot_Detector.
2629 *
2630 * @param mixed $detection Result of Metasync_Otto_Bot_Detector::detect(), or malformed input.
2631 * @return bool True when this request must bypass the render throttle.
2632 */
2633 function metasync_otto_is_unthrottled_infrastructure_agent( $detection = array() ) {
2634
2635 $ua = '';
2636 if ( is_array( $detection ) && ! empty( $detection['user_agent'] ) ) {
2637 $ua = (string) $detection['user_agent'];
2638 } elseif ( isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
2639 $ua = (string) $_SERVER['HTTP_USER_AGENT'];
2640 }
2641
2642 $needles = array(
2643 // Page-cache preloaders.
2644 'pagecachebot', // WP Cloud / Automattic (PageCacheBotDesktop|Mobile)
2645 'wp rocket', // WP Rocket preload
2646 'wprocket',
2647 'lscache_runner', // LiteSpeed Cache crawler
2648 'sg-optimizer', // SiteGround
2649 'sgoptimizer',
2650 'nitropack',
2651 'cache-warmer',
2652 'cachewarmer',
2653 'cache warmer',
2654 // Synthetic performance auditors.
2655 'lighthouse',
2656 'pagespeed',
2657 'gtmetrix',
2658 'pingdom',
2659 'webpagetest',
2660 );
2661
2662 $is_infra = false;
2663
2664 if ( '' !== $ua ) {
2665 $ua_lower = strtolower( $ua );
2666 foreach ( $needles as $needle ) {
2667 if ( false !== strpos( $ua_lower, $needle ) ) {
2668 $is_infra = true;
2669 break;
2670 }
2671 }
2672 }
2673
2674 // WP Cloud's warmer also tags its requests with a query marker.
2675 if ( ! $is_infra && isset( $_GET['x-cache-engine'] ) ) {
2676 $is_infra = true;
2677 }
2678
2679 /**
2680 * Allow a site or host to declare additional preload agents.
2681 *
2682 * @param bool $is_infra Whether this request bypasses the OTTO render throttle.
2683 * @param string $ua The request user-agent.
2684 */
2685 if ( function_exists( 'apply_filters' ) ) {
2686 $is_infra = (bool) apply_filters( 'metasync_otto_unthrottled_agent', $is_infra, $ua );
2687 }
2688
2689 return $is_infra;
2690 }
2691