PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
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
← All changes | otto/Otto_pixel_class.php +285 -15 2.6.8 → 2.7.1 View file →
@@ -100,10 +100,10 @@
100 100 return false;
101 101 }
102 102
103 103 # Parse saved domain + incoming URL
104 - $saved_domain = parse_url($saved['domain'], PHP_URL_HOST);
105 - $incoming_domain = parse_url($url, PHP_URL_HOST);
104 + $saved_domain = wp_parse_url($saved['domain'], PHP_URL_HOST);
105 + $incoming_domain = wp_parse_url($url, PHP_URL_HOST);
106 106
107 107 # Domain mismatch - not crawled
108 108 if (empty($saved_domain) || empty($incoming_domain) || strcasecmp($saved_domain, $incoming_domain) !== 0) {
109 109 return false;
@@ -109,9 +109,9 @@
109 109 return false;
110 110 }
111 111
112 112 # Parse incoming path (ignore query string)
113 - $parsed_url = parse_url($url);
113 + $parsed_url = wp_parse_url($url);
114 114 $url_path = $parsed_url['path'] ?? '/';
115 115
116 116 # Ensure path starts with / but don't modify trailing slashes
117 117 # Otto stores paths exactly as they appear in URLs
@@ -196,8 +196,36 @@
196 196
197 197 # render route html
198 198 function render_route_html(){
199 199
200 + # Headless frontends do not consume WordPress-rendered HTML, so avoid
201 + # suggestion lookups, buffering and internal fetches altogether.
202 + # Falls back to a guarded direct read rather than skipping the check:
203 + # silently skipping would render OTTO markup on a headless site.
204 + # @phpstan-ignore-next-line function.alreadyNarrowedType
205 + $headless = function_exists('metasync_headless_is_active')
206 + ? metasync_headless_is_active()
207 + : (class_exists('Metasync_Headless_Config') && Metasync_Headless_Config::is_active());
208 +
209 + if ($headless) {
210 + return;
211 + }
212 +
213 + # Clear per-request statics before any path runs. Harmless under PHP-FPM
214 + # (the process ends with the request) but required under persistent-worker
215 + # SAPIs, where a latched flag would silently disable cache capping and
216 + # failure reporting for every later request in that worker.
217 + #
218 + # method_exists() is redundant to static analysis — this MR adds the method,
219 + # so PHPStan proves the call always true. It is kept for the upgrade window:
220 + # during a plugin update an opcache can still hold the previous
221 + # Metasync_Otto_Render_Strategy, where class_exists() passes but the method
222 + # is absent, and an unguarded static call would fatal the page.
223 + # @phpstan-ignore-next-line function.alreadyNarrowedType
224 + if (class_exists('Metasync_Otto_Render_Strategy') && method_exists('Metasync_Otto_Render_Strategy', 'reset_request_state')) {
225 + Metasync_Otto_Render_Strategy::reset_request_state();
226 + }
227 +
200 228 # Disable SG Cache for Brizy pages FIRST - before any other processing
201 229 # Using global function defined in otto_pixel.php
202 230 if (function_exists('metasync_otto_disable_sg_cache_for_brizy')) {
203 231 metasync_otto_disable_sg_cache_for_brizy();
@@ -247,13 +275,36 @@
247 275 # Use get_suggestions directly to track cache status
248 276 $suggestions = $transient_cache->get_suggestions($route, $cache_track_key);
249 277
250 278 if (!$suggestions || !$transient_cache->has_payload($suggestions)) {
251 - # No suggestions available for this URL
252 - # Set header to indicate cache status
253 - if (!headers_sent()) {
254 - $cache_status = Metasync_Otto_Transient_Cache::get_cache_status($cache_track_key);
255 - header('X-MetaSync-OTTO-Cache: ' . ($cache_status ?: 'NO_SUGGESTIONS'));
279 + # No suggestions available for this URL.
280 + $cache_status = Metasync_Otto_Transient_Cache::get_cache_status($cache_track_key);
281 +
282 + # Two very different situations reach this point, and they must be
283 + # treated differently.
284 + #
285 + # A transient failure means the suggestions exist upstream but could
286 + # not be retrieved on this request — the API errored or timed out, the
287 + # per-minute budget was spent, or another worker held the lock. The
288 + # next request will probably succeed. Serving the un-optimized page
289 + # with normal cache headers lets a caching layer store it and hand it
290 + # to everyone until its TTL expires, so cap how long it may be kept.
291 + #
292 + # Everything else here is a legitimate answer: OTTO simply has nothing
293 + # for this URL (NO_SUGGESTIONS, including the 404 case). Most URLs on
294 + # most sites are in that state permanently, so capping those would
295 + # strip caching from the bulk of the site and move that load onto the
296 + # origin. Those stay fully cacheable.
297 + $transient_failures = ['RATE_LIMITED', 'LOCKED', 'API_ERROR'];
298 +
299 + if (in_array($cache_status, $transient_failures, true)
300 + && function_exists('metasync_otto_limit_response_cache')) {
301 + metasync_otto_limit_response_cache($cache_status);
302 + }
303 +
304 + # Set diagnostic headers only while Debug Mode is enabled.
305 + if (!headers_sent() && Metasync_Otto_Render_Strategy::diagnostics_enabled()) {
306 + header('X-MetaSync-OTTO-Cache: ' . Metasync_Otto_Render_Strategy::display_cache_status($cache_status ?: 'NO_SUGGESTIONS'));
256 307 header('X-MetaSync-OTTO-Method: NONE');
257 308 }
258 309 return;
259 310 }
@@ -259,9 +310,15 @@
259 310 }
260 311
261 312 # comment out for fixing pagination issues
262 313 # $route = rtrim($route, '/');
263 -
314 +
315 + # SiteGround's page cache is no longer disabled up-front for every OTTO page.
316 + # The cacheability decision now happens on the HTTP path in render_via_http(),
317 + # AFTER the internal fetch — only session/form pages and incomplete renders are
318 + # blocked, so plain content pages are finally cacheable. (SiteGround always uses
319 + # the HTTP path; the disable is a no-op off SiteGround, so nothing else loses it.)
320 +
264 321 # Analyze what OTTO is providing for SEO plugin blocking
265 322 $blocking_flags = $this->analyze_otto_blocking($suggestions);
266 323
267 324 # Get cache status for headers
@@ -323,8 +380,32 @@
323 380 }
324 381 }
325 382
326 383 /**
384 + * Stop WP Rocket writing the current response to its disk cache.
385 + *
386 + * DONOTCACHEPAGE is not sufficient from inside the rocket_buffer filter: WP
387 + * Rocket reads that constant when it decides whether to buffer the request,
388 + * which has already happened by the time the filter runs. It evaluates
389 + * do_rocket_generate_caching_files immediately before writing the file, so
390 + * that is the only lever still effective at this point.
391 + *
392 + * This matters more than the response headers do. A page served from WP
393 + * Rocket's disk cache is delivered by rewrite rules without invoking PHP, so
394 + * once a bad file is on disk no header can override it — only deleting the
395 + * file or waiting out Rocket's own expiry will clear it.
396 + *
397 + * PHP_INT_MAX priority so a site's own filters cannot re-enable the write.
398 + */
399 + private static function block_rocket_cache_write() {
400 + if (!defined('DONOTCACHEPAGE')) {
401 + define('DONOTCACHEPAGE', true);
402 + }
403 +
404 + add_filter('do_rocket_generate_caching_files', '__return_false', PHP_INT_MAX);
405 + }
406 +
407 + /**
327 408 * Render page using WP Rocket's rocket_buffer filter (PREFERRED for WP Rocket sites)
328 409 *
329 410 * When WP Rocket is active, hooking into rocket_buffer ensures WP Rocket
330 411 * caches the OTTO-modified HTML. This means:
@@ -345,12 +426,12 @@
345 426 # Hook into WP Rocket's HTML buffer at priority 1 so other rocket_buffer
346 427 # filters (minifiers, CDN rewriters, etc.) run on already-OTTO-modified HTML.
347 428 add_filter('rocket_buffer', function($html) use ($o_html, $suggestions, $blocking_flags) {
348 429 if (empty($html) || strlen($html) < 100) {
349 - // Tell WP Rocket not to cache this empty/broken response.
350 - if (!defined('DONOTCACHEPAGE')) {
351 - define('DONOTCACHEPAGE', true);
352 - }
430 + // Tell WP Rocket not to cache this empty/broken response. The
431 + // constant alone is too late here (see block_rocket_cache_write),
432 + // so the write-time filter is applied as well.
433 + self::block_rocket_cache_write();
353 434 return $html;
354 435 }
355 436
356 437 # Only process full HTML documents — skip partials, JSON, error responses
@@ -364,8 +445,23 @@
364 445 if (!empty($blocking_flags)) {
365 446 $data['_otto_blocking'] = $blocking_flags;
366 447 }
367 448
449 + # From here on we hold usable suggestions, so any exit that returns
450 + # $html unmodified is a genuine failure: WP Rocket would write the
451 + # un-optimized markup to its cache file, and whatever sits in front of
452 + # it would then serve that copy. This is worse than the plain buffer
453 + # path, because the bad page is persisted on disk as well as at the
454 + # edge — and this route exists specifically to make the cached copy the
455 + # optimized one.
456 + #
457 + # Note that DONOTCACHEPAGE alone does NOT prevent that write. WP Rocket
458 + # evaluates the constant when it decides whether to buffer the page,
459 + # which has already happened by the time this filter runs. The
460 + # do_rocket_generate_caching_files filter is evaluated immediately
461 + # before the file is written, so it is the only lever still available
462 + # from here. A disk-cached page is served by rewrite rules without
463 + # invoking PHP, so no response header can undo it afterwards.
368 464 try {
369 465 $modified = $o_html->process_html_directly($html, $data);
370 466 # Sanity check: modified HTML must be at least 50% the size of original
371 467 if ($modified && strlen($modified) > strlen($html) * 0.5) {
@@ -370,12 +466,64 @@
370 466 # Sanity check: modified HTML must be at least 50% the size of original
371 467 if ($modified && strlen($modified) > strlen($html) * 0.5) {
372 468 return $modified;
373 469 }
470 +
471 + # An unprocessable document (oversized / over the memory budget) is
472 + # a permanent property of the page, not a failure to retry. Leave it
473 + # fully cacheable — capping it would make the most expensive page on
474 + # the site uncacheable forever for no gain.
475 + if (class_exists('Metasync_Otto_Render_Strategy')
476 + && Metasync_Otto_Render_Strategy::document_was_unprocessable()) {
477 + return $html;
478 + }
479 +
480 + # Either the rewrite returned nothing usable, or the result was so
481 + # much smaller than the input that it is assumed to be mangled.
482 + self::block_rocket_cache_write();
483 + if (function_exists('metasync_otto_limit_response_cache')) {
484 + metasync_otto_limit_response_cache('RENDER_DISCARDED_ROCKET');
485 + }
486 + if (function_exists('metasync_otto_report_render_failure')) {
487 + metasync_otto_report_render_failure(
488 + 'RENDER_DISCARDED_ROCKET',
489 + 'OTTO rewrite produced no usable output on the WP Rocket path',
490 + [
491 + 'original_bytes' => strlen($html),
492 + 'result_bytes' => is_string($modified) ? strlen($modified) : 0,
493 + 'render_method' => 'wp_rocket',
494 + ],
495 + 'warning'
496 + );
497 + }
374 498 } catch (Exception $e) {
375 499 // Fall through — return original HTML on any failure
500 + self::block_rocket_cache_write();
501 + if (function_exists('metasync_otto_limit_response_cache')) {
502 + metasync_otto_limit_response_cache('RENDER_EXCEPTION_ROCKET');
503 + }
504 + if (function_exists('metasync_otto_report_render_failure')) {
505 + metasync_otto_report_render_failure(
506 + 'RENDER_EXCEPTION_ROCKET',
507 + 'OTTO rewrite threw on the WP Rocket path: ' . $e->getMessage(),
508 + ['render_method' => 'wp_rocket', 'exception' => get_class($e)],
509 + 'error'
510 + );
511 + }
376 512 } catch (Error $e) {
377 513 // Fall through — return original HTML on any failure
514 + self::block_rocket_cache_write();
515 + if (function_exists('metasync_otto_limit_response_cache')) {
516 + metasync_otto_limit_response_cache('RENDER_EXCEPTION_ROCKET');
517 + }
518 + if (function_exists('metasync_otto_report_render_failure')) {
519 + metasync_otto_report_render_failure(
520 + 'RENDER_EXCEPTION_ROCKET',
521 + 'OTTO rewrite errored on the WP Rocket path: ' . $e->getMessage(),
522 + ['render_method' => 'wp_rocket', 'error' => get_class($e)],
523 + 'error'
524 + );
525 + }
378 526 }
379 527
380 528 return $html;
381 529 }, 1);
@@ -515,18 +663,96 @@
515 663 private function render_via_http($route, $suggestions, $cache_track_key, $cache_status) {
516 664 # Set method indicator
517 665 Metasync_Otto_Render_Strategy::set_current_method(Metasync_Otto_Render_Strategy::METHOD_HTTP);
518 666
667 + # On Divi sites, skip OTTO for the first HTTP render per page
668 + # per 24h. OTTO's internal wp_remote_get creates corrupted CSS cache
669 + # files in et-cache/{post_id}/ (wrong server context). By returning
670 + # false once, WordPress renders the page normally — building correct
671 + # Divi CSS caches. Subsequent OTTO renders within 24h use those caches.
672 + # The transient stores the activation timestamp so it auto-invalidates
673 + # when the plugin is deactivated/reactivated.
674 + if (defined('ET_CORE_VERSION')) {
675 + $cache_key = 'otto_divi_css_fix_' . md5($route);
676 + $activated = get_option('metasync_activated_at', '');
677 + $cached = get_transient($cache_key);
678 + if ($cached === false || $cached !== $activated) {
679 + set_transient($cache_key, $activated, DAY_IN_SECONDS);
680 + # Deliberate one-off skip, and the next request renders correctly —
681 + # so cap how long this un-optimized copy may be cached rather than
682 + # letting a caching layer pin it for its full TTL.
683 + if (function_exists('metasync_otto_limit_response_cache')) {
684 + metasync_otto_limit_response_cache('DIVI_FIRST_RENDER_SKIP');
685 + }
686 + return false;
687 + }
688 + }
689 +
519 690 # check if we have the route html (pass suggestions to avoid duplicate API call)
520 691 $route_html = $this->get_route_html($route, $cache_track_key, $suggestions);
521 692
522 693 # check that route html is valid
523 694 if(empty($route_html)){
695 + # Fetch failed / empty (e.g. SG Optimizer truncated the internal response) —
696 + # WordPress will serve the un-optimized page. Cap how long that copy may be
697 + # cached, using the same mechanism as the deliberate skips above rather than a
698 + # second, competing one: it reconciles existing directives, keeps logged-in
699 + # responses fully no-store, and never grants caching nothing had granted.
700 + if (function_exists('metasync_otto_limit_response_cache')) {
701 + metasync_otto_limit_response_cache('HTTP_FETCH_FAILED');
702 + }
524 703 return false;
525 704 }
526 705
527 706 $route_html_string = is_string($route_html) ? $route_html : $route_html->__toString();
528 -
707 +
708 + # Detect incomplete Divi rendering in OTTO's HTTP render output.
709 + # OTTO's internal wp_remote_get runs in a different server context than the
710 + # normal page load. This can cause Divi to produce incomplete output:
711 + #
712 + # 1. Cold CSS cache: Divi outputs inline <style> blocks instead of external
713 + # <link> tags (TB CSS files not yet generated)
714 + # 2. Missing Google Fonts: Divi's font enqueue depends on request context
715 + # (cookies, headers) that differ in the internal fetch
716 + #
717 + # If OTTO serves this incomplete HTML via exit(), SG Optimizer caches it —
718 + # breaking CSS for all visitors until manual cache purge.
719 + #
720 + # Fix: skip OTTO for this request (return false), letting WordPress render
721 + # normally. This builds Divi's caches so the next OTTO request works correctly.
722 + #
723 + # Both checks below are self-resolving — the next render finds warm caches —
724 + # so the un-optimized copy served now gets a capped cache lifetime rather
725 + # than being pinned for a caching layer's full TTL.
726 + #
727 + # The remaining fail-open exits on this path are deliberately NOT capped.
728 + # They mean the site could not fetch its own page (blocked loopback request,
729 + # HTTP Basic Auth, a firewall rejecting the internal user-agent) or that the
730 + # document cannot be processed at all. Those are whole-site, permanent
731 + # conditions: OTTO cannot work on any page, so capping cache lifetime would
732 + # rescue nothing while moving the entire site's traffic to the origin.
733 + if (defined('ET_CORE_VERSION')) {
734 + # Check 1: Cold TB CSS cache (inline styles instead of external link)
735 + if (preg_match('/<style\s[^>]*id=["\']et-core-unified-tb-[^"\']*-cached-inline-styles["\']/', $route_html_string)) {
736 + if (function_exists('metasync_otto_limit_response_cache')) {
737 + metasync_otto_limit_response_cache('DIVI_COLD_CSS_CACHE');
738 + }
739 + return false;
740 + }
741 + # Check 2: Missing Google Fonts CSS (Divi enqueues this on every page)
742 + # Normal render has: <link ... id='et-builder-googlefonts-cached-css' ...>
743 + # or <link ... id='et-builder-googlefonts-css' ...>
744 + # If neither is present, the internal fetch didn't load fonts properly.
745 + if (strpos($route_html_string, 'et-builder-googlefonts') === false
746 + && strpos($route_html_string, 'fonts.googleapis.com') === false
747 + ) {
748 + if (function_exists('metasync_otto_limit_response_cache')) {
749 + metasync_otto_limit_response_cache('DIVI_MISSING_FONTS');
750 + }
751 + return false;
752 + }
753 + }
754 +
529 755 if (strpos($route_html_string, 'pix-sliding-headline-2') !== false || strpos($route_html_string, 'pix-intro-sliding-text') !== false) {
530 756 # Only apply fix within sliding text contexts to avoid breaking other layouts
531 757 $route_html_string = preg_replace('#(</span></span>)(<span\s+class=["\'][^"\']*slide-in-container[^"\']*["\'][^>]*>)#i', '$1 $2', $route_html_string);
532 758 }
@@ -566,11 +792,55 @@
566 792 $discarded_output = ob_get_clean();
567 793
568 794 }
569 795
796 + # If Divi's module-design CSS is missing from OTTO output,
797 + # fetch it directly and inject it. This handles the case where the
798 + # internal wp_remote_get response was truncated by SG Optimizer's parser.
799 + if (strpos($route_html_string, 'et-builder-module-design') === false
800 + && function_exists('et_theme_builder_decorate_page_resource_slug')
801 + && function_exists('et_core_page_resource_get')
802 + ) {
803 + # Try to get Divi's inline CSS from its page resource manager
804 + $post_id = get_the_ID();
805 + if ($post_id) {
806 + $resource_slug = et_theme_builder_decorate_page_resource_slug($post_id, 'module-design');
807 + $manager = et_core_page_resource_get('builder', $resource_slug, $post_id, 40);
808 + if ($manager && method_exists($manager, 'get_data')) {
809 + $css_data = $manager->get_data('inline');
810 + if (!empty($css_data)) {
811 + $style_tag = '<style id="et-builder-' . esc_attr($resource_slug) . '-cached-inline-styles">'
812 + . wp_strip_all_tags($css_data) . '</style>';
813 + $route_html_string = str_replace('</body>', $style_tag . "\n" . '</body>', $route_html_string);
814 + }
815 + }
816 + }
817 + }
818 +
819 + # Decide cacheability from the internal fetch + the final output, then serve.
820 + # 1) Hand the visitor the session their page was built around (fixes forms that
821 + # were silently failing on the HTTP path because the fetch cookie was dropped).
822 + # 2) Block shared caching when the fetched page set a cookie, when its own render
823 + # declared itself uncacheable (PHP's session cache-limiter — true for resumed
824 + # sessions too), when the visitor carries cart/auth cookies that change the
825 + # rendered content, or when the render came back incomplete.
826 + # 3) A plain, complete page gets no cache header at all → the host finally caches
827 + # it (the SiteGround/Oxygen relief this ticket is about).
828 + Metasync_Otto_Render_Strategy::passthrough_http_fetch_cookies();
829 +
830 + $must_not_cache =
831 + Metasync_Otto_Render_Strategy::http_fetch_had_cookie()
832 + || Metasync_Otto_Render_Strategy::http_fetch_no_cache()
833 + || Metasync_Otto_Render_Strategy::request_has_visitor_specific_cookie()
834 + || !Metasync_Otto_Render_Strategy::http_output_is_complete($route_html_string);
835 +
570 836 # Send response headers
571 837 Metasync_Otto_Render_Strategy::send_headers($cache_status);
572 838
839 + if ($must_not_cache) {
840 + Metasync_Otto_Render_Strategy::block_http_cache();
841 + }
842 +
573 843 # continue to render the html
574 844 echo $route_html_string;
575 845
576 846 # prevent further wp execution
@@ -576,5 +846,5 @@
576 846 # prevent further wp execution
577 847 exit();
578 848 }
579 849
580 -}
850 +}