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
metasync / otto / Otto_pixel_class.php

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

851 lines 38.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // If this file is called directly, abort.
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * This class handles the Otto Pixel Functions
9 */
10 Class Metasync_otto_pixel{
11
12 #otto html class
13 public $o_html;
14
15 # crawl data option
16 public $option_name = 'metasync_otto_crawldata';
17
18 # no cache wp pages
19 public $no_cache_pages = ['wp-login.php'];
20
21 # OTTO UUID
22 private $otto_uuid;
23
24 #
25 function __construct($otto_uuid){
26
27 # store the UUID
28 $this->otto_uuid = $otto_uuid;
29
30 # load the html class
31 $this->o_html = new Metasync_otto_html($otto_uuid);
32
33 }
34
35 # method to handle cache refresh
36 # NOTE: Cache system removed - always process in real-time
37 function refresh_cache($route){
38 # No-op: Cache system has been removed
39 # All pages are processed in real-time from OTTO API
40 return true;
41 }
42
43
44 # method to save crawl data into
45 function save_crawl_data($data){
46
47 # get the option name
48 $option_name = $this->option_name;
49
50 # handle data
51 $saved = get_option($option_name);
52
53 # log saved
54
55 # if saved false save
56 if(empty($saved['urls'])){
57
58 # save the option
59 update_option($option_name, $data);
60
61 return;
62 }
63
64 # get new unique list of urls
65 $new_list = array_unique(array_merge($data['urls'], $saved['urls']));
66
67 # set data
68 $data['urls'] = $new_list;
69
70 # log saved
71
72 # save the option
73 update_option($option_name, $data);
74 }
75
76 /**
77 * Check if a URL has been crawled by Otto
78 * - Validates domain against saved domain
79 * - Ignores query strings
80 * - Does exact path matching as Otto stores paths exactly as they appear
81 * @param string $url - Full URL to check
82 * @return bool - True if URL was crawled, false otherwise
83 */
84 function is_url_crawled($url) {
85 # Ensure valid input
86 if (empty($url) || !is_string($url)) {
87 return false;
88 }
89
90 # Get saved crawl data
91 $saved = get_option($this->option_name);
92
93 if (
94 empty($saved) ||
95 !is_array($saved) ||
96 empty($saved['domain']) ||
97 empty($saved['urls']) ||
98 !is_array($saved['urls'])
99 ) {
100 return false;
101 }
102
103 # Parse saved domain + incoming URL
104 $saved_domain = wp_parse_url($saved['domain'], PHP_URL_HOST);
105 $incoming_domain = wp_parse_url($url, PHP_URL_HOST);
106
107 # Domain mismatch - not crawled
108 if (empty($saved_domain) || empty($incoming_domain) || strcasecmp($saved_domain, $incoming_domain) !== 0) {
109 return false;
110 }
111
112 # Parse incoming path (ignore query string)
113 $parsed_url = wp_parse_url($url);
114 $url_path = $parsed_url['path'] ?? '/';
115
116 # Ensure path starts with / but don't modify trailing slashes
117 # Otto stores paths exactly as they appear in URLs
118 if (substr($url_path, 0, 1) !== '/') {
119 $url_path = '/' . $url_path;
120 }
121
122 # Compare against crawled URLs - exact match
123 foreach ($saved['urls'] as $crawled_url) {
124 if (!is_string($crawled_url)) {
125 continue;
126 }
127
128 # Direct comparison - Otto stores paths exactly as they are
129 if (strcasecmp($crawled_url, $url_path) === 0) {
130 return true;
131 }
132 }
133
134 return false;
135 }
136
137 # get the current route
138 function get_route(){
139
140 # check if we're in an HTTP context
141 if(empty($_SERVER['HTTP_HOST']) || empty($_SERVER['REQUEST_URI'])){
142 # not in HTTP context (CLI, cron, etc.), return false
143 return false;
144 }
145
146 # get req scheme
147 $scheme = ( is_ssl() ? 'https' : 'http' );
148
149 # get req host
150 $host = $_SERVER['HTTP_HOST'];
151
152 # get the uri — strip query string before building the route.
153 # OTTO suggestions are canonical-URL-based; query parameters (Kinsta cache-bypass
154 # tokens, UTM params, tracking params, etc.) never produce different OTTO content.
155 # Stripping them ensures that:
156 # - The transient populated via a Kinsta BYPASS request (always has ?params) is
157 # shared with the clean-URL MISS request (no params).
158 # - The OTTO API is called with the canonical URL, not a noisy variant.
159 $request_uri = strtok($_SERVER['REQUEST_URI'], '?') ?: $_SERVER['REQUEST_URI'];
160
161 # return the formatted url
162 return $scheme . '://' . $host . $request_uri;
163 }
164
165 # get the html for a route
166 # OPTION 1 IMPLEMENTATION: Use transient cache for suggestions
167 function get_route_html($route, $cache_track_key = null, $suggestions = null){
168 # If suggestions already provided (from render_route_html), use them directly
169 if ($suggestions !== null && is_array($suggestions)) {
170 # Process route with provided suggestions data
171 return $this->o_html->process_route_with_data($route, $suggestions, '');
172 }
173
174 # Get OTTO UUID from options
175 global $metasync_options;
176 $otto_uuid = $metasync_options['general']['otto_pixel_uuid'] ?? '';
177
178 if (empty($otto_uuid)) {
179 return false;
180 }
181
182 # Get suggestions from transient cache (with API fallback)
183 $transient_cache = new Metasync_Otto_Transient_Cache($otto_uuid);
184 $track_key = $cache_track_key ?: md5($route);
185 $suggestions = $transient_cache->get_suggestions($route, $track_key);
186
187 if (!$suggestions || !$transient_cache->has_payload($suggestions)) {
188 # No suggestions available
189 return false;
190 }
191
192 # Process route with cached suggestions data
193 return $this->o_html->process_route_with_data($route, $suggestions, '');
194 }
195
196
197 # render route html
198 function render_route_html(){
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
228 # Disable SG Cache for Brizy pages FIRST - before any other processing
229 # Using global function defined in otto_pixel.php
230 if (function_exists('metasync_otto_disable_sg_cache_for_brizy')) {
231 metasync_otto_disable_sg_cache_for_brizy();
232 }
233
234 # get the route
235 $route = $this->get_route();
236
237 # get the current page from globas
238 # this is to help us exclude the login page
239
240 $page_now = $GLOBALS['pagenow'] ?? false;
241
242 # check whether page now in excluded pages
243 if(in_array($page_now , $this->no_cache_pages)){
244
245 # stop otto
246 return;
247 }
248
249 /*
250 #uncomment to test on local hosts
251 if(!empty($_GET['otto_test'])){
252
253 # @dev
254 $route = 'https://staging-perm.wp65.qa.internal.searchatlas.com/';
255 }
256 */
257
258 # OPTION 1 IMPLEMENTATION: Check transient cache instead of notification data
259 # This makes the system self-healing - works even if notifications fail
260
261 # Get OTTO UUID from options
262 global $metasync_options;
263 $otto_uuid = $metasync_options['general']['otto_pixel_uuid'] ?? '';
264
265 if (empty($otto_uuid)) {
266 return;
267 }
268
269 $transient_cache = new Metasync_Otto_Transient_Cache($otto_uuid);
270
271 # Create tracking key for cache status
272 $cache_track_key = 'otto_' . md5($route);
273
274 # Check if URL has OTTO suggestions (checks transient, calls API if needed)
275 # Use get_suggestions directly to track cache status
276 $suggestions = $transient_cache->get_suggestions($route, $cache_track_key);
277
278 if (!$suggestions || !$transient_cache->has_payload($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'));
307 header('X-MetaSync-OTTO-Method: NONE');
308 }
309 return;
310 }
311
312 # comment out for fixing pagination issues
313 # $route = rtrim($route, '/');
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
321 # Analyze what OTTO is providing for SEO plugin blocking
322 $blocking_flags = $this->analyze_otto_blocking($suggestions);
323
324 # Get cache status for headers
325 $cache_status = Metasync_Otto_Transient_Cache::get_cache_status($cache_track_key);
326
327 # RENDER PRIORITY:
328 # 1. WP Rocket rocket_buffer filter — WP Rocket caches OTTO-modified HTML,
329 # so Kinsta also caches the correct version. No exit(), no cache bypass.
330 # 2. Output Buffer (fast) — for environments without WP Rocket.
331 # 3. HTTP Request (last resort) — exits early, bypasses all caching.
332 try {
333 # Safety check: ensure render strategy class exists
334 if (!class_exists('Metasync_Otto_Render_Strategy')) {
335 # Class not loaded - use HTTP method as fallback
336 $this->render_via_http($route, $suggestions, $cache_track_key, $cache_status);
337 return;
338 }
339
340 # WP ROCKET PATH: highest priority when WP Rocket is active.
341 # rocket_buffer fires inside WP Rocket's own cache pipeline, so the
342 # cache file WP Rocket writes (and that Kinsta stores) is already
343 # OTTO-modified. Eliminates the race where WP Rocket saved pre-OTTO HTML.
344 if (class_exists('WP_Rocket')) {
345 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
346 if ($wp_rocket_compat_mode !== 'http') {
347 # disable_otto exits in metasync_start_otto() before we get here, so
348 # only 'http' compat mode needs to skip the rocket_buffer path.
349 $this->render_via_rocket_buffer($suggestions, $blocking_flags, $cache_status);
350 return;
351 }
352 }
353
354 $render_method = Metasync_Otto_Render_Strategy::determine_method();
355
356 if ($render_method === Metasync_Otto_Render_Strategy::METHOD_BUFFER) {
357 # FAST PATH: Use output buffer approach
358 $result = $this->render_via_buffer($route, $suggestions, $blocking_flags, $cache_status);
359
360 if ($result === true) {
361 # Buffer is now active, WordPress will continue rendering
362 # OTTO modifications will be applied when buffer flushes
363 return;
364 }
365
366 # Buffer approach failed, fall back to HTTP method
367 $render_method = Metasync_Otto_Render_Strategy::METHOD_HTTP;
368 }
369
370 if ($render_method === Metasync_Otto_Render_Strategy::METHOD_HTTP) {
371 # FALLBACK PATH: Use traditional HTTP request approach
372 $this->render_via_http($route, $suggestions, $cache_track_key, $cache_status);
373 }
374 } catch (Exception $e) {
375 # Any exception in the render strategy - fall back to HTTP method
376 $this->render_via_http($route, $suggestions, $cache_track_key, $cache_status);
377 } catch (Error $e) {
378 # PHP 7+ Error (like TypeError) - fall back to HTTP method
379 $this->render_via_http($route, $suggestions, $cache_track_key, $cache_status);
380 }
381 }
382
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 /**
408 * Render page using WP Rocket's rocket_buffer filter (PREFERRED for WP Rocket sites)
409 *
410 * When WP Rocket is active, hooking into rocket_buffer ensures WP Rocket
411 * caches the OTTO-modified HTML. This means:
412 * - WP Rocket's cache file = OTTO-modified HTML ✓
413 * - Kinsta FastCGI cache = WP Rocket output = OTTO-modified HTML ✓
414 * - No exit() = proper cache lifecycle ✓
415 *
416 * The filter fires on every uncached PHP request. Once WP Rocket has cached
417 * the result, subsequent requests are served directly from cache with zero PHP.
418 *
419 * @param array $suggestions OTTO suggestions data
420 * @param array $blocking_flags SEO plugin blocking flags
421 * @param string $cache_status Cache status for X-MetaSync-* headers
422 */
423 private function render_via_rocket_buffer($suggestions, $blocking_flags, $cache_status) {
424 $o_html = $this->o_html;
425
426 # Hook into WP Rocket's HTML buffer at priority 1 so other rocket_buffer
427 # filters (minifiers, CDN rewriters, etc.) run on already-OTTO-modified HTML.
428 add_filter('rocket_buffer', function($html) use ($o_html, $suggestions, $blocking_flags) {
429 if (empty($html) || strlen($html) < 100) {
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();
434 return $html;
435 }
436
437 # Only process full HTML documents — skip partials, JSON, error responses
438 if (stripos($html, '<html') === false && stripos($html, '<!DOCTYPE') === false) {
439 return $html;
440 }
441
442 # Pass blocking context to the HTML processor (suppresses Yoast/Rank Math tags
443 # that OTTO is replacing, preventing duplicates in the final HTML)
444 $data = $suggestions;
445 if (!empty($blocking_flags)) {
446 $data['_otto_blocking'] = $blocking_flags;
447 }
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.
464 try {
465 $modified = $o_html->process_html_directly($html, $data);
466 # Sanity check: modified HTML must be at least 50% the size of original
467 if ($modified && strlen($modified) > strlen($html) * 0.5) {
468 return $modified;
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 }
498 } catch (Exception $e) {
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 }
512 } catch (Error $e) {
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 }
526 }
527
528 return $html;
529 }, 1);
530
531 # Block SEO plugins before wp_head fires so duplicate tags aren't output
532 $description_tags = $blocking_flags['block_description_tags'] ?? [];
533 $has_description_tags = !empty($description_tags);
534 if ($blocking_flags['block_title'] || $has_description_tags) {
535 if (function_exists('metasync_otto_block_seo_plugins')) {
536 metasync_otto_block_seo_plugins(
537 $blocking_flags['block_title'],
538 $has_description_tags,
539 $description_tags
540 );
541 }
542 }
543
544 # Send X-MetaSync-* diagnostic headers before WordPress outputs anything
545 if (!headers_sent()) {
546 Metasync_Otto_Render_Strategy::set_current_method(Metasync_Otto_Render_Strategy::METHOD_WP_ROCKET);
547 Metasync_Otto_Render_Strategy::send_headers($cache_status);
548 }
549
550 # Return — WordPress continues its normal render lifecycle.
551 # WP Rocket's buffer captures the full HTML, fires rocket_buffer,
552 # our callback modifies it, and WP Rocket caches the OTTO version.
553 }
554
555 /**
556 * Analyze OTTO suggestions to determine what to block from SEO plugins
557 *
558 * @param array $suggestions OTTO suggestions data
559 * @return array Blocking flags
560 */
561 private function analyze_otto_blocking($suggestions) {
562 $has_otto_title = false;
563 $otto_description_tags = []; // Track specific description tags Otto provides
564
565 if (!empty($suggestions['header_replacements']) && is_array($suggestions['header_replacements'])) {
566 foreach ($suggestions['header_replacements'] as $item) {
567 if (!empty($item['type'])) {
568 # Check if OTTO has title
569 if ($item['type'] == 'title' && !empty($item['recommended_value'])) {
570 $has_otto_title = true;
571 }
572 # Check if OTTO has description - track specific tag types
573 if ($item['type'] == 'meta') {
574 # Check for meta[name=description]
575 if (!empty($item['name']) && $item['name'] == 'description' && !empty($item['recommended_value'])) {
576 $otto_description_tags[] = 'meta[name=description]';
577 }
578 # Check for meta[property=og:description]
579 if (!empty($item['property']) && $item['property'] == 'og:description' && !empty($item['recommended_value'])) {
580 $otto_description_tags[] = 'meta[property=og:description]';
581 }
582 # Check for meta[name=twitter:description]
583 if (!empty($item['name']) && $item['name'] == 'twitter:description' && !empty($item['recommended_value'])) {
584 $otto_description_tags[] = 'meta[name=twitter:description]';
585 }
586 }
587 }
588 }
589 }
590
591 # Check header_html_insertion for description
592 # Must have a non-empty content value, otherwise Yoast would be blocked with nothing to replace it
593 if (!empty($suggestions['header_html_insertion'])) {
594 if (preg_match('/<meta[^>]*name=["\']description["\'][^>]*content=["\']([^"\']+)["\'][^>]*>/i', $suggestions['header_html_insertion'])) {
595 $otto_description_tags[] = 'meta[name=description]';
596 }
597 }
598
599 # Remove duplicates
600 $otto_description_tags = array_unique($otto_description_tags);
601
602 return [
603 'block_title' => $has_otto_title,
604 'block_description_tags' => $otto_description_tags, // Pass array of specific tags
605 ];
606 }
607
608 /**
609 * Render page using output buffer approach (FAST)
610 * Eliminates the internal HTTP request by capturing WordPress output directly
611 *
612 * @param string $route Current page route
613 * @param array $suggestions OTTO suggestions data
614 * @param array $blocking_flags SEO plugin blocking flags
615 * @param string $cache_status Cache status for headers
616 * @return bool True if buffer started successfully, false to fall back to HTTP
617 */
618 private function render_via_buffer($route, $suggestions, $blocking_flags, $cache_status) {
619 # Try to start output buffer
620 $buffer_started = Metasync_Otto_Render_Strategy::start_buffer(
621 $suggestions,
622 $route,
623 $this->o_html,
624 $blocking_flags
625 );
626
627 if (!$buffer_started) {
628 # Buffer failed to start
629 return false;
630 }
631
632 # Buffer is active - send headers now (before any output)
633 if (!headers_sent()) {
634 Metasync_Otto_Render_Strategy::send_headers($cache_status);
635 }
636
637 # Block SEO plugins if needed (for the buffered output)
638 $description_tags = $blocking_flags['block_description_tags'] ?? [];
639 $has_description_tags = !empty($description_tags);
640 if ($blocking_flags['block_title'] || $has_description_tags) {
641 if (function_exists('metasync_otto_block_seo_plugins')) {
642 metasync_otto_block_seo_plugins(
643 $blocking_flags['block_title'],
644 $has_description_tags,
645 $description_tags
646 );
647 }
648 }
649
650 # Return true - WordPress will continue rendering, buffer will capture and process
651 return true;
652 }
653
654 /**
655 * Render page using HTTP request approach (FALLBACK)
656 * Makes internal wp_remote_get request to fetch page HTML
657 *
658 * @param string $route Current page route
659 * @param array $suggestions OTTO suggestions data
660 * @param string $cache_track_key Cache tracking key
661 * @param string $cache_status Cache status for headers
662 */
663 private function render_via_http($route, $suggestions, $cache_track_key, $cache_status) {
664 # Set method indicator
665 Metasync_Otto_Render_Strategy::set_current_method(Metasync_Otto_Render_Strategy::METHOD_HTTP);
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
690 # check if we have the route html (pass suggestions to avoid duplicate API call)
691 $route_html = $this->get_route_html($route, $cache_track_key, $suggestions);
692
693 # check that route html is valid
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 }
703 return false;
704 }
705
706 $route_html_string = is_string($route_html) ? $route_html : $route_html->__toString();
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
755 if (strpos($route_html_string, 'pix-sliding-headline-2') !== false || strpos($route_html_string, 'pix-intro-sliding-text') !== false) {
756 # Only apply fix within sliding text contexts to avoid breaking other layouts
757 $route_html_string = preg_replace('#(</span></span>)(<span\s+class=["\'][^"\']*slide-in-container[^"\']*["\'][^>]*>)#i', '$1 $2', $route_html_string);
758 }
759
760 # Fix for Elementor widgets - preserve whitespace between inline spans
761 # This prevents text/elements from appearing merged when HTML is minified
762
763 # Fix for Elementor social icons (elementor-grid-item)
764 if (strpos($route_html_string, 'elementor-social-icons-wrapper') !== false) {
765 # Add whitespace between closing and opening span tags within elementor-grid-item
766 $route_html_string = preg_replace(
767 '#(</span>)(<span\s+class=["\'][^"\']*elementor-grid-item[^"\']*["\'][^>]*>)#i',
768 '$1 $2',
769 $route_html_string
770 );
771 }
772
773 # Fix for Elementor animated headline (elementor-headline-text-wrapper)
774 if (strpos($route_html_string, 'elementor-headline') !== false) {
775 # Add whitespace between closing and opening span tags with elementor-headline-text-wrapper
776 $route_html_string = preg_replace(
777 '#(</span>)(<span\s+class=["\'][^"\']*elementor-headline-text-wrapper[^"\']*["\'][^>]*>)#i',
778 '$1 $2',
779 $route_html_string
780 );
781 }
782
783 # Check for Revolution Slider to determine if special handling is needed
784 # Check for both Revolution Slider 6 (<rs-module-wrap>) and Revolution Slider 7 (<sr7-module>)
785 $has_revslider = (strpos($route_html_string, '<rs-module-wrap') !== false || strpos($route_html_string, '<sr7-module') !== false);
786
787 if($has_revslider){
788 # Revolution Slider detected - fire WordPress hooks to ensure proper initialization
789 # Use output buffering to prevent hooks from corrupting Otto's processed HTML
790 ob_start();
791 do_action('wp_enqueue_scripts');
792 $discarded_output = ob_get_clean();
793
794 }
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
836 # Send response headers
837 Metasync_Otto_Render_Strategy::send_headers($cache_status);
838
839 if ($must_not_cache) {
840 Metasync_Otto_Render_Strategy::block_http_cache();
841 }
842
843 # continue to render the html
844 echo $route_html_string;
845
846 # prevent further wp execution
847 exit();
848 }
849
850 }
851