. Deliberately a comment # rather than an attribute on the tag — see mark_head_optimized(). const HEAD_OPTIMIZED_TOKEN = 'metasync_optimized'; const HEAD_OPTIMIZED_MARKER = ''; # html dom private $dom; # the file path to save to private $html_file; # uuid of site private $site_uuid; # the otto endpoint url private $otto_end_point; # PERFORMANCE OPTIMIZATION: Defer DOM reload until final save # Reduces 6-10 serialize/deserialize cycles to just 1 private $deferred_reload = false; # PERFORMANCE OPTIMIZATION: Cache commonly accessed DOM elements # Eliminates 8-10 full DOM traversals per page private $cached_elements = []; /** * Escape bare < characters in text content that SimpleHtmlDom misinterprets as HTML tags. * Example: "<4 microns" gets parsed as a tag, corrupting the DOM and breaking page layout. * Only targets < followed by digits (never valid HTML tag starts). * Protects there * closes the enclosing script early, strands the rest of its code as a text * node in , and the browser therefore ends at that point — * pushing title, canonical, OG/Twitter and JSON-LD into , where * crawlers and social scrapers ignore them. * * A comment placed immediately after the tag carries the same signal and * leaves the tag byte-identical. Note that an HTML minifier may strip * comments, so treat the tag as the durable marker; this * one is a convenience for support and QA. * * Only the first is marked. The real tag is always the first one in * the document, so this pass cannot wander the way a bare literal match can. * * @param string $html Serialized HTML. * @return string */ private function mark_head_optimized($html) { # Idempotent, and also a no-op on pages still carrying the legacy # attribute form (e.g. HTML already in a page cache). if ($html === '' || strpos($html, self::HEAD_OPTIMIZED_TOKEN) !== false) { return $html; } $out = preg_replace( '/(]*)?>)/i', '$1' . self::HEAD_OPTIMIZED_MARKER, $html, 1 ); # Belt-and-suspenders: if PCRE fails for any reason, keep the input. return $out === null ? $html : $out; } /** * Apply OTTO's canonical via string replacement (reliable fallback). * * Why the DOM path fails: do_header_replacements() sets the canonical with a * DOM attribute edit ($link->href = …). That edit works in isolation, but * insert_header_html() runs *earlier* in the same pipeline and reassigns * $head->outertext to a raw string (to inject OTTO's schema). In SimpleHtmlDom, * assigning ->outertext freezes that node — now serializes as that * literal string and no longer re-renders from its child node tree. The * canonical is captured inside that frozen string with its OLD href, so * the later $link->href edit is never reflected in the output and the SEO * plugin's (Yoast/Rank Math/AIOSEO) canonical always wins. This is the same * head-freeze that forced title/meta onto string-replacement fallbacks; * canonical was the one case that never got one. * * This pass runs on the final serialized HTML (after the freeze), so it is * immune to the ordering problem. It guarantees exactly one canonical — * OTTO's: it removes every existing and inserts OTTO's * recommended value (marked data-otto="true") right after . Manual * canonicals (_metasync_canonical_url / meta_canonical) still take priority, * matching the DOM path's protection. * * @param string $html Serialized HTML. * @param array $replacement_data OTTO suggestions. * @return string */ private function apply_canonical_via_string($html, $replacement_data) { # Canonical feature switched off — do not apply OTTO's canonical, and do # not strip the canonical the page already carries. if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::CANONICAL)) { return $html; } if (!is_string($html) || empty($replacement_data['header_replacements']) || !is_array($replacement_data['header_replacements'])) { return $html; } # Find OTTO's canonical recommendation. $canonical = ''; foreach ($replacement_data['header_replacements'] as $item) { if (($item['type'] ?? '') === 'link' && ($item['rel'] ?? '') === 'canonical') { $canonical = $item['recommended_value'] ?? $item['value'] ?? ''; break; } } if (empty($canonical) || !is_string($canonical)) { return $html; } # Validate OTTO's own suggestion too: if the platform payload # carries a corrupted/non-URL canonical, leave the document untouched. $canonical = Metasync_Canonical_Sanitizer::sanitize($canonical); if ($canonical === '') { return $html; } # Respect a manually-set canonical (same protection as the DOM path). # Validated: a legacy row corrupted to "Array" must not count # as a manual canonical — that would suppress OTTO's correct value. if (function_exists('is_singular') && is_singular()) { $post_id = function_exists('get_the_ID') ? get_the_ID() : 0; if ($post_id) { $custom = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, '_metasync_canonical_url', true)); if ($custom === '') { $custom = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, 'meta_canonical', true)); } if ($custom !== '') { return $html; # manual canonical wins — leave the document untouched } } } # Only proceed if there is a to place the tag in (never end up with zero canonical). if (!preg_match('#]*>#i', $html)) { return $html; } $tag = ''; # Remove every existing canonical link (bounded per-tag pattern; null-safe). $stripped = preg_replace('#]*\brel=(["\'])canonical\1[^>]*>\s*#i', '', $html); if (is_string($stripped)) { $html = $stripped; } # Insert OTTO's canonical right after (callback avoids $/\ interpolation from the URL). $inserted = preg_replace_callback('#(]*>)#i', function ($m) use ($tag) { return $m[1] . "\n" . $tag; }, $html, 1); if (is_string($inserted)) { $html = $inserted; } return $html; } /** * Keep the charset declaration within the first 1024 bytes of . * * OTTO injects meta tags + JSON-LD schema at the top of , which can push * the theme's past the 1024-byte limit that browsers * enforce for in-document charset detection (HTML spec). When that happens — * and a cached/CDN response is served without an HTTP charset header — the * document falls back to the locale encoding (Windows-1252). External * stylesheets that declare no @charset of their own (e.g. a theme rule like * `content:"\2713"` written as a raw UTF-8 ✓) then inherit that wrong encoding * and render as mojibake ("âœ"" instead of "✓"). * * We guarantee a as the first child of whenever * one isn't already present within the first 1024 bytes. Harmless when a valid * early charset already exists (we skip), and a duplicate later declaration is * ignored by the browser (first one wins). * * @param string $html * @return string */ private function ensure_early_charset_meta($html) { if (!preg_match('/]*>/i', $html, $m, PREG_OFFSET_CAPTURE)) { return $html; } $inner_start = $m[0][1] + strlen($m[0][0]); # Already declared early enough? Leave it alone (also covers AMP, which # requires charset as the first child). $window = substr($html, $inner_start, 1024); if (preg_match('/]*charset/i', $window)) { return $html; } return substr($html, 0, $inner_start) . '' . substr($html, $inner_start); } # function __construct($otto_uuid){ # set the site uuid using the provided string $this->site_uuid = $otto_uuid; # Use endpoint manager if available, otherwise fallback to production if (class_exists('Metasync_Endpoint_Manager')) { $this->otto_end_point = Metasync_Endpoint_Manager::get_endpoint('OTTO_URL_DETAILS'); } else { $this->otto_end_point = 'https://sa.searchatlas.com/api/v2/otto-url-details'; } # laod the simple html dom parser with UTF-8 charset to handle special characters $this->dom = new HtmlDocument(null, true, true, 'UTF-8', false); } /** * Check Route Method * @param route : The route to check * @param path : The path of the html file to save */ function process_route($route, $file_path){ # Construct the full endpoint URL with query parameters $url_with_params = add_query_arg( [ 'url' => $route, 'uuid' => $this->site_uuid, ], $this->otto_end_point ); # PERFORMANCE FIX: Add timeout to prevent blocking $args = array( 'timeout' => 5, // 5 second max timeout (allow time for redirects) 'redirection' => 5, // CRITICAL FIX: Allow redirects (API returns 301) 'user-agent' => 'MetaSync-OTTO-SSR/2.0', 'sslverify' => true ); # Perform the GET request with timeout $response = wp_remote_get($url_with_params, $args); # Check for errors if (is_wp_error($response)) { error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API call failed - ' . $response->get_error_message()); return false; } # get the response body $body = wp_remote_retrieve_body($response); # Get the response code $response_code = wp_remote_retrieve_response_code($response); # if no change data skip if (empty($body) || $response_code !== 200){ error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API returned empty or non-200. Code: ' . $response_code); return false; } # set the html file path $this->html_file = $file_path; # load change data $change_data = json_decode($body, true); # Process with the fetched data return $this->process_route_with_data($route, $change_data, $file_path); } /** * Process route with pre-fetched suggestions data * OPTION 1: Used when data comes from transient cache * @param route : The route to check * @param change_data : Pre-fetched OTTO suggestions data * @param path : The path of the html file to save */ function process_route_with_data($route, $change_data, $file_path){ if (empty($change_data) || !is_array($change_data)) { return false; } # set the html file path $this->html_file = $file_path; # Analyze what Otto is providing and store for conditional SEO blocking $has_otto_title = false; $otto_description_tags = []; // Track specific description tags Otto provides if (!empty($change_data['header_replacements']) && is_array($change_data['header_replacements'])) { foreach ($change_data['header_replacements'] as $item) { if (!empty($item['type'])) { # Check if Otto has title if ($item['type'] == 'title' && !empty($item['recommended_value'])) { $has_otto_title = true; } # Check if Otto has description - track specific tag types if ($item['type'] == 'meta') { # Check for meta[name=description] if (!empty($item['name']) && $item['name'] == 'description' && !empty($item['recommended_value'])) { $otto_description_tags[] = 'meta[name=description]'; } # Check for meta[property=og:description] if (!empty($item['property']) && $item['property'] == 'og:description' && !empty($item['recommended_value'])) { $otto_description_tags[] = 'meta[property=og:description]'; } # Check for meta[name=twitter:description] if (!empty($item['name']) && $item['name'] == 'twitter:description' && !empty($item['recommended_value'])) { $otto_description_tags[] = 'meta[name=twitter:description]'; } } } } } # Check header_html_insertion for description - must have non-empty content value if (!empty($change_data['header_html_insertion'])) { if (preg_match('/]*name=["\']description["\'][^>]*content=["\']([^"\']+)["\'][^>]*>/i', $change_data['header_html_insertion'])) { $otto_description_tags[] = 'meta[name=description]'; } } # Remove duplicates $otto_description_tags = array_unique($otto_description_tags); # Store blocking flags to pass to handle_route_html # This will be added to the internal fetch URL as parameters $change_data['_otto_blocking'] = array( 'block_title' => $has_otto_title, 'block_description_tags' => $otto_description_tags // Pass array of specific tags to remove ); # Process the route with the suggestions data return $this->handle_route_html($route, $change_data); } # function to get tag attributes function get_tag_attributes($tag){ # Extract existing attributes of the tag $attributes = []; # set the tag attributes $tag_attributes = []; # check that the tag attributes if(!is_object($tag) || !method_exists($tag, 'getAllAttributes')){ return ''; } # get the tag attributes $tag_attributes = $tag->getAllAttributes(); # loop all attributes foreach ($tag_attributes as $key => $value) { if ($value == 1) { # Handle boolean attributes $attributes[] = htmlspecialchars($key, ENT_QUOTES); } else { # Handle attributes with values $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"'; } } # Convert attributes array to a string $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : ''; # return the attributes string return $attributes_string; } function handle_route_html($route, $replacement_data){ # Detect if current page uses Brizy and disable SG Cache if so # Using global function defined in otto_pixel.php if (function_exists('metasync_otto_disable_sg_cache_for_brizy')) { metasync_otto_disable_sg_cache_for_brizy(); } # lablel the Otto Route # label otto requests to avoid loops // $request_body = add_query_arg( // [ // 'is_otto_page_fetch' => 1 // ], // $route // ); # Add blocking flags as URL parameters (no database writes!) $url_params = ['is_otto_page_fetch' => 1]; # Add blocking flags if available if (!empty($replacement_data['_otto_blocking'])) { $url_params['otto_block_title'] = $replacement_data['_otto_blocking']['block_title'] ? '1' : '0'; # For HTTP fetch path, check if any description tags need blocking $block_description_tags = $replacement_data['_otto_blocking']['block_description_tags'] ?? []; $url_params['otto_block_desc'] = !empty($block_description_tags) ? '1' : '0'; } $request_body = add_query_arg($url_params, $route); # TUNNEL/PROXY SUPPORT: If site is behind a tunnel (ngrok, zrok, etc.) # and loopback requests fail, try using localhost instead $request_body = apply_filters('metasync_otto_internal_fetch_url', $request_body, $route); # set cookie header var $cookie_header = ''; # loop cookies to set header foreach ($_COOKIE as $name => $value) { # handle array values by converting to string $cookie_value = is_array($value) ? serialize($value) : $value; # add cookie to header # $cookie_header .= $name . '=' . $value . '; '; $cookie_header .= $name . '=' . $cookie_value . '; '; } # trim the string $cookie_header = rtrim($cookie_header, '; '); # Allow timeout customization for slow tunnel environments $fetch_timeout = apply_filters('metasync_otto_internal_fetch_timeout', 5); $args = array( 'sslverify' => false, // Disabled for localhost/tunnel environments 'timeout' => $fetch_timeout, // Configurable timeout for tunnels 'redirection' => 5, 'httpversion' => '1.1', 'headers' => array( 'Cookie' => $cookie_header, 'Cache-Control' => 'no-cache, no-store, must-revalidate', 'Pragma' => 'no-cache', 'X-OTTO-Internal-Fetch' => '1', 'User-Agent' => 'MetaSync-OTTO-SSR/3.0', 'X-Forwarded-Host' => $_SERVER['HTTP_HOST'] ?? '', // Preserve original host for tunnels ) ); # get the associateed route html $route_html = wp_remote_get($request_body, $args); # Check for timeout or connection errors if (is_wp_error($route_html)) { $error_msg = $route_html->get_error_message(); error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - wp_remote_get error: ' . $error_msg . ' for route: ' . $route); return false; } # get body $html_body = wp_remote_retrieve_body($route_html); # Get the response code $response_code = wp_remote_retrieve_response_code($route_html); // Requests follows redirects and returns the final body with its final // status. Do not put that body on the original route when a redirect // changed the URL: doing so creates a soft redirect with mismatched // content and SEO metadata. The normal WordPress request can handle // the redirect, and OTTO will process the destination on its own URL. $effective_url = $this->get_effective_fetch_url($route_html); if ($effective_url !== '' && !$this->fetch_url_matches_route($effective_url, $route, $request_body)) { error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: SKIPPED - internal fetch followed redirect from ' . $route . ' to ' . $effective_url); return false; } # Capture the internal fetch's `Set-Cookie`. On the HTTP path OTTO keeps only the # body below and would otherwise discard this — but it is both the form/session # signal for the caching decision AND (same-origin only) the session the visitor # needs for their forms to work. $request_body is the URL actually fetched (it may # be rewritten to localhost for tunnels), so only allow pass-through when its # scheme+host match the visitor's; otherwise we would set a wrong-domain cookie. if (class_exists('Metasync_Otto_Render_Strategy')) { $fetch_set_cookie = wp_remote_retrieve_header($route_html, 'set-cookie'); $fetch_parts = wp_parse_url($request_body); $visitor_host = preg_replace('/:\d+$/', '', $_SERVER['HTTP_HOST'] ?? ''); $same_origin = !empty($fetch_parts['host']) && strcasecmp($fetch_parts['host'], $visitor_host) === 0 && ((($fetch_parts['scheme'] ?? '') === 'https') === (bool) is_ssl()); Metasync_Otto_Render_Strategy::set_http_fetch_cookie($fetch_set_cookie, $same_origin); # Also record the internal response's own `Cache-Control`. When the page's # render starts a PHP session, PHP emits its session cache-limiter headers # (`no-store, no-cache, must-revalidate`) — and it does so whether the session # is new OR resumed, so this catches session pages that set no NEW cookie and # would otherwise look cacheable. It is read as a signal only and never # forwarded to the visitor: our own no-cache request header is not echoed into # the response, so this cannot make ordinary pages uncacheable. Metasync_Otto_Render_Strategy::set_http_fetch_cache_control( wp_remote_retrieve_header($route_html, 'cache-control') ); } # check not empty if(empty($html_body) || $response_code !== 200){ error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - Empty body or non-200 status for route: ' . $route); return false; } # Skip DOM processing on oversized documents to avoid fatal OOM. if (class_exists('Metasync_Otto_Render_Strategy') && !Metasync_Otto_Render_Strategy::is_document_processable(strlen($html_body)) ) { Metasync_Otto_Render_Strategy::log_oversized_skip('handle_route_html', strlen($html_body)); return false; } # Remove XML declaration $html_body = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html_body); # / Save ALL original