PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.5
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.5
2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / otto / Otto_html_class.php

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

2,210 lines 89.2 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 # uses the simple html dom library
9 use simplehtmldom\HtmlWeb;
10 use simplehtmldom\HtmlDocument;
11
12 /**
13 * This Class Handles
14 */
15
16 Class Metasync_otto_html{
17
18 # html dom
19 private $dom;
20
21 # the file path to save to
22 private $html_file;
23
24 # uuid of site
25 private $site_uuid;
26
27 # the otto endpoint url
28 private $otto_end_point;
29
30 # PERFORMANCE OPTIMIZATION: Defer DOM reload until final save
31 # Reduces 6-10 serialize/deserialize cycles to just 1
32 private $deferred_reload = false;
33
34 # PERFORMANCE OPTIMIZATION: Cache commonly accessed DOM elements
35 # Eliminates 8-10 full DOM traversals per page
36 private $cached_elements = [];
37
38 /**
39 * Escape bare < characters in text content that SimpleHtmlDom misinterprets as HTML tags.
40 * Example: "<4 microns" gets parsed as a tag, corrupting the DOM and breaking page layout.
41 * Only targets < followed by digits (never valid HTML tag starts).
42 * Protects <script> and <style> blocks where < appears in code.
43 */
44 private function sanitize_text_less_than($html) {
45 $protected = [];
46 $html = preg_replace_callback(
47 '/<(script|style)([\s>])(.*?)<\/\1>/si',
48 function($match) use (&$protected) {
49 $placeholder = '<!--METASYNC_PROTECTED_' . count($protected) . '-->';
50 $protected[$placeholder] = $match[0];
51 return $placeholder;
52 },
53 $html
54 );
55
56 $html = preg_replace('/<(?=\d)/', '&lt;', $html);
57
58 if (!empty($protected)) {
59 $html = str_replace(array_keys($protected), array_values($protected), $html);
60 }
61
62 return $html;
63 }
64
65 /**
66 * Restore case-sensitive SVG and HTML5 attributes that SimpleHtmlDom lowercases.
67 * The DOM parser's $lowercase=true flag (required for find() queries) lowercases
68 * all attribute names, breaking case-sensitive attributes like viewBox.
69 * Applied on final HTML output after all DOM processing is complete.
70 */
71 private function restore_case_sensitive_attributes($html) {
72 static $map = [
73 ' viewbox=' => ' viewBox=',
74 ' preserveaspectratio=' => ' preserveAspectRatio=',
75 ' controlslist=' => ' controlsList=',
76 ];
77 return str_ireplace(array_keys($map), array_values($map), $html);
78 }
79
80 #
81 function __construct($otto_uuid){
82
83 # set the site uuid using the provided string
84 $this->site_uuid = $otto_uuid;
85
86 # Use endpoint manager if available, otherwise fallback to production
87 if (class_exists('Metasync_Endpoint_Manager')) {
88 $this->otto_end_point = Metasync_Endpoint_Manager::get_endpoint('OTTO_URL_DETAILS');
89 } else {
90 $this->otto_end_point = 'https://sa.searchatlas.com/api/v2/otto-url-details';
91 }
92
93 # laod the simple html dom parser with UTF-8 charset to handle special characters
94 $this->dom = new HtmlDocument(null, true, true, 'UTF-8', false);
95 }
96
97 /**
98 * Check Route Method
99 * @param route : The route to check
100 * @param path : The path of the html file to save
101 */
102 function process_route($route, $file_path){
103
104 # Construct the full endpoint URL with query parameters
105 $url_with_params = add_query_arg(
106 [
107 'url' => $route,
108 'uuid' => $this->site_uuid,
109 ],
110 $this->otto_end_point
111 );
112
113 # PERFORMANCE FIX: Add timeout to prevent blocking
114 $args = array(
115 'timeout' => 5, // 5 second max timeout (allow time for redirects)
116 'redirection' => 5, // CRITICAL FIX: Allow redirects (API returns 301)
117 'user-agent' => 'MetaSync-OTTO-SSR/2.0',
118 'sslverify' => true
119 );
120
121 # Perform the GET request with timeout
122 $response = wp_remote_get($url_with_params, $args);
123
124 # Check for errors
125 if (is_wp_error($response)) {
126 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API call failed - ' . $response->get_error_message());
127 return false;
128 }
129
130 # get the response body
131 $body = wp_remote_retrieve_body($response);
132
133 # Get the response code
134 $response_code = wp_remote_retrieve_response_code($response);
135
136 # if no change data skip
137 if (empty($body) || $response_code !== 200){
138 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API returned empty or non-200. Code: ' . $response_code);
139 return false;
140 }
141
142 # set the html file path
143 $this->html_file = $file_path;
144
145 # load change data
146 $change_data = json_decode($body, true);
147
148 # Process with the fetched data
149 return $this->process_route_with_data($route, $change_data, $file_path);
150 }
151
152 /**
153 * Process route with pre-fetched suggestions data
154 * OPTION 1: Used when data comes from transient cache
155 * @param route : The route to check
156 * @param change_data : Pre-fetched OTTO suggestions data
157 * @param path : The path of the html file to save
158 */
159 function process_route_with_data($route, $change_data, $file_path){
160
161 if (empty($change_data) || !is_array($change_data)) {
162 return false;
163 }
164
165 # set the html file path
166 $this->html_file = $file_path;
167
168 # Analyze what Otto is providing and store for conditional SEO blocking
169 $has_otto_title = false;
170 $otto_description_tags = []; // Track specific description tags Otto provides
171
172 if (!empty($change_data['header_replacements']) && is_array($change_data['header_replacements'])) {
173 foreach ($change_data['header_replacements'] as $item) {
174 if (!empty($item['type'])) {
175 # Check if Otto has title
176 if ($item['type'] == 'title' && !empty($item['recommended_value'])) {
177 $has_otto_title = true;
178 }
179 # Check if Otto has description - track specific tag types
180 if ($item['type'] == 'meta') {
181 # Check for meta[name=description]
182 if (!empty($item['name']) && $item['name'] == 'description' && !empty($item['recommended_value'])) {
183 $otto_description_tags[] = 'meta[name=description]';
184 }
185 # Check for meta[property=og:description]
186 if (!empty($item['property']) && $item['property'] == 'og:description' && !empty($item['recommended_value'])) {
187 $otto_description_tags[] = 'meta[property=og:description]';
188 }
189 # Check for meta[name=twitter:description]
190 if (!empty($item['name']) && $item['name'] == 'twitter:description' && !empty($item['recommended_value'])) {
191 $otto_description_tags[] = 'meta[name=twitter:description]';
192 }
193 }
194 }
195 }
196 }
197
198 # Check header_html_insertion for description - must have non-empty content value
199 if (!empty($change_data['header_html_insertion'])) {
200 if (preg_match('/<meta[^>]*name=["\']description["\'][^>]*content=["\']([^"\']+)["\'][^>]*>/i', $change_data['header_html_insertion'])) {
201 $otto_description_tags[] = 'meta[name=description]';
202 }
203 }
204
205 # Remove duplicates
206 $otto_description_tags = array_unique($otto_description_tags);
207
208 # Store blocking flags to pass to handle_route_html
209 # This will be added to the internal fetch URL as parameters
210 $change_data['_otto_blocking'] = array(
211 'block_title' => $has_otto_title,
212 'block_description_tags' => $otto_description_tags // Pass array of specific tags to remove
213 );
214
215 # Process the route with the suggestions data
216 return $this->handle_route_html($route, $change_data);
217
218 }
219
220 # function to get tag attributes
221 function get_tag_attributes($tag){
222
223 # Extract existing attributes of the <body> tag
224 $attributes = [];
225
226
227 # set the tag attributes
228 $tag_attributes = [];
229
230
231 # check that the tag attributes
232 if(!is_object($tag) || !method_exists($tag, 'getAllAttributes')){
233 return '';
234 }
235
236 # get the tag attributes
237 $tag_attributes = $tag->getAllAttributes();
238
239 # loop all attributes
240 foreach ($tag_attributes as $key => $value) {
241
242 if ($value == 1) {
243
244 # Handle boolean attributes
245 $attributes[] = htmlspecialchars($key, ENT_QUOTES);
246 } else {
247
248 # Handle attributes with values
249 $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
250 }
251 }
252
253 # Convert attributes array to a string
254 $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : '';
255
256 # return the attributes string
257 return $attributes_string;
258 }
259
260 function handle_route_html($route, $replacement_data){
261
262 # Detect if current page uses Brizy and disable SG Cache if so
263 # Using global function defined in otto_pixel.php
264 if (function_exists('metasync_otto_disable_sg_cache_for_brizy')) {
265 metasync_otto_disable_sg_cache_for_brizy();
266 }
267
268 # lablel the Otto Route
269 # label otto requests to avoid loops
270 // $request_body = add_query_arg(
271 // [
272 // 'is_otto_page_fetch' => 1
273 // ],
274 // $route
275 // );
276 # Add blocking flags as URL parameters (no database writes!)
277 $url_params = ['is_otto_page_fetch' => 1];
278
279 # Add blocking flags if available
280 if (!empty($replacement_data['_otto_blocking'])) {
281 $url_params['otto_block_title'] = $replacement_data['_otto_blocking']['block_title'] ? '1' : '0';
282 # For HTTP fetch path, check if any description tags need blocking
283 $block_description_tags = $replacement_data['_otto_blocking']['block_description_tags'] ?? [];
284 $url_params['otto_block_desc'] = !empty($block_description_tags) ? '1' : '0';
285 }
286
287 $request_body = add_query_arg($url_params, $route);
288
289 # TUNNEL/PROXY SUPPORT: If site is behind a tunnel (ngrok, zrok, etc.)
290 # and loopback requests fail, try using localhost instead
291 $request_body = apply_filters('metasync_otto_internal_fetch_url', $request_body, $route);
292 # set cookie header var
293 $cookie_header = '';
294
295 # loop cookies to set header
296 foreach ($_COOKIE as $name => $value) {
297
298 # handle array values by converting to string
299 $cookie_value = is_array($value) ? serialize($value) : $value;
300
301 # add cookie to header
302 # $cookie_header .= $name . '=' . $value . '; ';
303 $cookie_header .= $name . '=' . $cookie_value . '; ';
304 }
305
306 # trim the string
307 $cookie_header = rtrim($cookie_header, '; ');
308
309 # Allow timeout customization for slow tunnel environments
310 $fetch_timeout = apply_filters('metasync_otto_internal_fetch_timeout', 5);
311
312 $args = array(
313 'sslverify' => false, // Disabled for localhost/tunnel environments
314 'timeout' => $fetch_timeout, // Configurable timeout for tunnels
315 'redirection' => 5,
316 'httpversion' => '1.1',
317 'headers' => array(
318 'Cookie' => $cookie_header,
319 'Cache-Control' => 'no-cache, no-store, must-revalidate',
320 'Pragma' => 'no-cache',
321 'X-OTTO-Internal-Fetch' => '1',
322 'User-Agent' => 'MetaSync-OTTO-SSR/3.0',
323 'X-Forwarded-Host' => $_SERVER['HTTP_HOST'] ?? '', // Preserve original host for tunnels
324 )
325 );
326
327 # get the associateed route html
328 $route_html = wp_remote_get($request_body, $args);
329
330 # Check for timeout or connection errors
331 if (is_wp_error($route_html)) {
332 $error_msg = $route_html->get_error_message();
333 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - wp_remote_get error: ' . $error_msg . ' for route: ' . $route);
334 return false;
335 }
336
337 # get body
338 $html_body = wp_remote_retrieve_body($route_html);
339
340 # Get the response code
341 $response_code = wp_remote_retrieve_response_code($route_html);
342
343
344 # check not empty
345 if(empty($html_body) || $response_code !== 200){
346 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - Empty body or non-200 status for route: ' . $route);
347 return false;
348 }
349
350 # Remove XML declaration
351 $html_body = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html_body);
352
353 # Escape bare < in text content (e.g. "<4 microns") before DOM parsing
354 $html_body = $this->sanitize_text_less_than($html_body);
355
356 # now that the html is not empty
357 # load it into the simple html dom
358 $this->dom->load($html_body, true, false);
359
360 # Force UTF-8 charset to preserve emojis and special characters
361 # This overrides any charset detection from HTML meta tags
362 $this->dom->_charset = 'UTF-8';
363 $this->dom->_target_charset = 'UTF-8';
364
365 # PERFORMANCE OPTIMIZATION: Pre-cache commonly accessed DOM elements
366 # This eliminates 8-10 full DOM traversals per page
367 $this->cache_elements();
368
369 # PERFORMANCE OPTIMIZATION: Enable deferred reload to skip intermediate reloads
370 # This reduces 6-10 DOM serialize/deserialize cycles to just 1 final reload
371 $this->deferred_reload = true;
372
373 # now lets do the magic
374
375 # COMPATIBILITY FIX: Transform 'value' to 'recommended_value' if needed
376 # Some API versions return 'value' instead of 'recommended_value'
377 if (!empty($replacement_data['header_replacements'])) {
378 foreach ($replacement_data['header_replacements'] as &$item) {
379 if (isset($item['value']) && !isset($item['recommended_value'])) {
380 $item['recommended_value'] = $item['value'];
381 }
382 }
383 unset($item); // Break reference
384 }
385
386 # start the header html insertion
387 $this->insert_header_html($replacement_data);
388
389 # now we do the header replacements
390 $this->do_header_replacements($replacement_data);
391
392 # now do the body replacements
393 $this->do_body_replacements($replacement_data);
394
395 # now do the footer insertions
396 $this->do_footer_html_insertion($replacement_data);
397
398 # final cleanup: ensure metasync_optimized attribute is removed from AMP pages
399 $this->cleanup_amp_metasync_attribute();
400
401 # CRITICAL FIX: SimpleHtmlDom save() doesn't persist outertext/innertext changes
402 # Use the same manual string replacement approach as process_html_directly
403 $this->deferred_reload = false;
404
405 # Get the HTML as string
406 $result_html = $this->dom->save();
407
408 # Apply manual replacements (same logic as process_html_directly)
409
410 # Apply header replacements manually
411 if (!empty($replacement_data['header_replacements'])) {
412 foreach ($replacement_data['header_replacements'] as $item) {
413 $type = $item['type'] ?? '';
414 $value = $item['recommended_value'] ?? $item['value'] ?? '';
415
416 if (empty($value)) continue;
417
418 if ($type === 'title') {
419 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
420 $title_tag = '<title>' . $new_value . '</title>';
421 $result_html = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
422 return $title_tag;
423 }, $result_html, 1);
424 } elseif ($type === 'meta') {
425 $name = $item['name'] ?? '';
426 $property = $item['property'] ?? '';
427 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
428
429 if (!empty($name)) {
430 $pattern = '/<meta\s+(?:name\s*=\s*["\']' . preg_quote($name, '/') . '["\']\s+content\s*=\s*["\'][^"\']*["\']|content\s*=\s*["\'][^"\']*["\']\s+name\s*=\s*["\']' . preg_quote($name, '/') . '["\'])\s*\/?>/i';
431 $replacement = '<meta name="' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
432 $count = 0;
433 $result_html = preg_replace_callback($pattern, function ($m) use ($replacement) {
434 return $replacement;
435 }, $result_html, -1, $count);
436
437 if ($count === 0) {
438 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
439 return $m[1] . "\n" . $replacement;
440 }, $result_html, 1);
441 }
442 } elseif (!empty($property)) {
443 $pattern = '/<meta\s+property\s*=\s*["\']' . preg_quote($property, '/') . '["\']\s+content\s*=\s*["\'][^"\']*["\']\s*\/?>/i';
444 $replacement = '<meta property="' . htmlspecialchars($property, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
445 $count = 0;
446 $result_html = preg_replace_callback($pattern, function ($m) use ($replacement) {
447 return $replacement;
448 }, $result_html, -1, $count);
449
450 if ($count === 0) {
451 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
452 return $m[1] . "\n" . $replacement;
453 }, $result_html, 1);
454 }
455 }
456 } elseif ($type === 'h1' || $type === 'heading') {
457 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
458 $result_html = preg_replace_callback(
459 '/<h1([^>]*)>.*?<\/h1>/is',
460 function ($m) use ($new_value) {
461 return '<h1' . $m[1] . '>' . $new_value . '</h1>';
462 },
463 $result_html,
464 1
465 );
466 }
467 }
468 }
469
470 # String-based heading fallback for body_substitutions
471 # DOM changes via SimpleHtmlDom don't persist on Divi/page-builder sites
472 if (!empty($replacement_data['body_substitutions']['headings']) && is_array($replacement_data['body_substitutions']['headings'])) {
473 foreach ($replacement_data['body_substitutions']['headings'] as $heading) {
474 if (empty($heading['type']) || empty($heading['current_value']) || empty($heading['recommended_value'])) {
475 continue;
476 }
477
478 $heading_type = preg_quote($heading['type'], '/');
479 $current_value = trim(preg_replace('/\s+/', ' ', html_entity_decode($heading['current_value'], ENT_QUOTES, 'UTF-8')));
480 $recommended_value = htmlspecialchars($heading['recommended_value'], ENT_QUOTES, 'UTF-8');
481
482 $result_html = preg_replace_callback(
483 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
484 function ($m) use ($current_value, $recommended_value) {
485 $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
486 if ($inner_text === $current_value) {
487 return $m[1] . $recommended_value . $m[3];
488 }
489 return $m[0];
490 },
491 $result_html,
492 -1
493 );
494 }
495 }
496
497 # CRITICAL FIX: Apply image alt text manually via string replacement
498 # DOM changes via SimpleHtmlDom don't persist on Oxygen/page-builder sites using HTTP render path
499 $result_html = $this->apply_image_alt_text_via_string($result_html, $replacement_data);
500
501 # Apply insertions — only if DOM insertion didn't already apply it
502 if (!empty($replacement_data['header_html_insertion'])) {
503 $header_html_check = trim($replacement_data['header_html_insertion']);
504 if (strpos($result_html, $header_html_check) === false) {
505 $header_html_insertion = preg_replace(
506 '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
507 '<script$1type=$2application/ld+json$2 data-otto="true"',
508 $replacement_data['header_html_insertion']
509 );
510 $safe_header = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
511 $result_html = preg_replace('/(<\/head>)/i', $safe_header . "\n" . '$1', $result_html, 1);
512 }
513 }
514 if (!empty($replacement_data['body_top_html_insertion'])) {
515 $body_top_check = trim($replacement_data['body_top_html_insertion']);
516 if (strpos($result_html, $body_top_check) === false) {
517 $safe_body_top = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_top_html_insertion']);
518 $result_html = preg_replace('/(<body[^>]*>)/i', '$1' . "\n" . $safe_body_top, $result_html, 1);
519 }
520 }
521 if (!empty($replacement_data['body_bottom_html_insertion'])) {
522 $body_bottom_check = trim($replacement_data['body_bottom_html_insertion']);
523 if (strpos($result_html, $body_bottom_check) === false) {
524 $safe_body_bottom = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_bottom_html_insertion']);
525 $result_html = preg_replace('/(<\/body>)/i', $safe_body_bottom . "\n" . '$1', $result_html, 1);
526 }
527 }
528 if (!empty($replacement_data['footer_html_insertion'])) {
529 $safe_footer = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['footer_html_insertion']);
530 $result_html = preg_replace('/(<\/html>)/i', $safe_footer . "\n" . '$1', $result_html, 1);
531 }
532
533 # DEDUPLICATION: Remove duplicate <title>, OG, Twitter tags, canonical, and JSON-LD schema
534 $result_html = $this->deduplicate_title_tags($result_html);
535 $result_html = $this->deduplicate_og_twitter_tags($result_html);
536 $result_html = $this->deduplicate_schema_tags($result_html);
537 $result_html = $this->deduplicate_canonical_tags($result_html);
538
539 # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
540 if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
541 $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
542 }
543
544 $result_html = $this->restore_case_sensitive_attributes($result_html);
545
546 return $result_html;
547 }
548
549 # do the footer html insertion
550 function do_footer_html_insertion($replacement_data){
551
552 # check that we have footer html
553 if(empty($replacement_data['footer_html_insertion'])){
554 return;
555 }
556
557 # OPTIMIZED: Use cached element instead of DOM traversal
558 $footer = $this->get_cached_element('footer');
559
560 # check that footer is object
561 if(!is_object($footer) || !isset($footer->innertext, $footer->outertext)){
562 return;
563 }
564
565 # get the tag attributes
566 $attributes_string = $this->get_tag_attributes($footer);
567
568 # now do the actual html replacements
569 $footer->outertext = '<footer' . $attributes_string . '>' . $footer->innertext . $replacement_data['footer_html_insertion'].'</footer>';
570
571 # save the document
572 $this->save_reload();
573 }
574
575 # do body replacements
576 function do_body_replacements($replacement_data){
577
578 # start body top html replacements
579 $this->do_body_top_html($replacement_data);
580
581 # start the body bottom html replacements
582 $this->do_body_bottom_html($replacement_data);
583
584 # do the body substitutions
585 $this->do_body_substitutions($replacement_data);
586
587 # Check if the feature is enabled in general settings (Post/Page Editor Settings)
588 $general_settings = get_option('metasync_options')['general'] ?? [];
589 if (!empty($general_settings['open_external_links']) && $general_settings['open_external_links'] == '1') {
590 $this->add_target_blank_to_external_links();
591 }
592
593 # Check if the feature is enabled in seo_controls settings (Indexation Control)
594 $seo_controls = get_option('metasync_options')['seo_controls'] ?? [];
595 if (!empty($seo_controls['add_nofollow_to_external_links']) && $seo_controls['add_nofollow_to_external_links'] === 'true') {
596 $this->add_nofollow_to_external_links();
597 }
598 }
599
600 # body substitutions data
601 function do_body_substitutions($replacement_data){
602
603 # check that we have an array of substitutions
604 if(empty($replacement_data['body_substitutions']) || !is_array($replacement_data['body_substitutions'])){
605 return;
606 }
607
608 # now work on different substitution keys
609 foreach ($replacement_data['body_substitutions'] as $key => $value) {
610
611 # check key categories
612 if($key == 'images'){
613 # do image replacements
614 $this->handle_images($value);
615 }
616 elseif($key == 'headings'){
617 # do heading repalcements
618 $this->do_heading_body_substitutions($value);
619 }
620 elseif($key == 'links'){
621 # do link replacements
622 $this->do_link_body_substitutions($value);
623 }
624
625 }
626
627 # save the document
628 $this->save_reload();
629
630 }
631
632 /**
633 * START BODY SUBSTITUTION FUNCTIONS
634 * @see do_body_substitutions();
635 */
636
637 # image substitions
638 function handle_images($image_data){
639
640 if (empty($image_data) || !is_array($image_data)) {
641 return;
642 }
643
644 # OPTIMIZED: Use cached images instead of DOM traversal
645 $images = $this->get_cached_element('imgs', []);
646
647 if (empty($images)) {
648 return;
649 }
650
651 # PERFORMANCE OPTIMIZATION: O(n²) reduced to O(n)
652 # Single pass with hash map lookup instead of nested loop
653 foreach($images AS $key => $image){
654 # Get image src
655 $image_src = $image->src;
656
657 if (empty($image_src)) {
658 continue;
659 }
660
661 # Hash map lookup O(1) instead of loop O(n)
662 if (isset($image_data[$image_src])) {
663 # Set alt text - Note: This may not persist in all cases
664 # Manual string replacement in process_html_directly() ensures it's applied
665 $new_alt = htmlspecialchars($image_data[$image_src], ENT_QUOTES, 'UTF-8');
666
667 # Get current img tag HTML and update alt attribute
668 $current_html = $image->outertext;
669
670 # Remove existing alt attribute (if any)
671 $updated_html = preg_replace('/\s+alt=(["\'])[^"\']*\1/', '', $current_html);
672
673 # Insert new alt attribute after the opening <img
674 $updated_html = preg_replace('/^<img\s/', '<img alt="' . str_replace('$', '\\$', $new_alt) . '" ', $updated_html);
675
676 # Update the element
677 $image->outertext = $updated_html;
678
679 $multi_view_attr = $image->getAttribute('data-et-multi-view');
680 if (!empty($multi_view_attr)) {
681 $this->update_divi_multi_view_alt($image, $image_data[$image_src]);
682 }
683 }
684 }
685 }
686
687 /**
688 * Update Divi's multi-view data attribute with alt text
689 * Divi stores image attributes in a JSON structure within data-et-multi-view
690 *
691 * @param object $image The image DOM element
692 * @param string $alt_text The alt text to set
693 */
694 private function update_divi_multi_view_alt($image, $alt_text) {
695 $multi_view_attr = $image->getAttribute('data-et-multi-view');
696
697 if (!empty($multi_view_attr)) {
698 try {
699 # Decode the JSON
700 $multi_view_data = json_decode($multi_view_attr, true);
701
702 if ($multi_view_data && isset($multi_view_data['schema']['attrs'])) {
703 # Update alt in desktop view
704 if (isset($multi_view_data['schema']['attrs']['desktop'])) {
705 $multi_view_data['schema']['attrs']['desktop']['alt'] = $alt_text;
706 }
707
708 # Update alt in other views if they exist (phone, tablet, etc.)
709 foreach ($multi_view_data['schema']['attrs'] as $view => $attrs) {
710 if (isset($attrs['alt'])) {
711 $multi_view_data['schema']['attrs'][$view]['alt'] = $alt_text;
712 }
713 }
714
715 # Encode back to JSON and update the attribute
716 $updated_json = json_encode($multi_view_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
717 $image->setAttribute('data-et-multi-view', $updated_json);
718
719 # error_log('MetaSync OTTO DEBUG: Updated Divi multi-view alt text');
720 }
721 } catch (Exception $e) {
722 # If JSON decode fails, just log and continue
723 # error_log('MetaSync OTTO DEBUG: Failed to update Divi multi-view - ' . $e->getMessage());
724 }
725 }
726 }
727
728 /**
729 * Apply image alt text via string replacement on raw HTML.
730 * Used as a fallback when DOM changes don't persist (Oxygen, Divi, page-builder sites).
731 *
732 * @param string $html The HTML to process
733 * @param array $replacement_data The replacement data containing body_substitutions.images
734 * @return string Modified HTML with alt text applied
735 */
736 private function apply_image_alt_text_via_string($html, $replacement_data) {
737 if (empty($replacement_data['body_substitutions']['images']) || !is_array($replacement_data['body_substitutions']['images'])) {
738 return $html;
739 }
740
741 foreach ($replacement_data['body_substitutions']['images'] as $image_url => $alt_text) {
742 if (empty($alt_text) || strpos($html, $image_url) === false) {
743 continue;
744 }
745
746 $escaped_alt = htmlspecialchars($alt_text, ENT_QUOTES, 'UTF-8');
747 $escaped_url = preg_quote($image_url, '/');
748 $img_pattern = '/<img[^>]*src=["\']' . $escaped_url . '["\'][^>]*>/i';
749
750 if (preg_match_all($img_pattern, $html, $img_matches)) {
751 foreach ($img_matches[0] as $original_img) {
752 if (strpos($original_img, $escaped_alt) !== false) {
753 continue;
754 }
755
756 # Remove ALL existing alt attributes
757 $new_img = preg_replace('/\s+alt\s*=\s*(["\'])[^"\']*\1/i', '', $original_img);
758 $new_img = preg_replace('/<img\s+alt\s*=\s*(["\'])[^"\']*\1\s*/i', '<img ', $new_img);
759
760 # Add single alt attribute after <img
761 $new_img = preg_replace('/^<img\s*/i', '<img alt="' . str_replace('$', '\\$', $escaped_alt) . '" ', $new_img);
762
763 # Update data-et-multi-view JSON if present (Divi theme)
764 if (strpos($new_img, 'data-et-multi-view') !== false) {
765 $new_img = preg_replace_callback(
766 '/data-et-multi-view="([^"]+)"/i',
767 function($mv_matches) use ($alt_text) {
768 $json_str = html_entity_decode($mv_matches[1], ENT_QUOTES, 'UTF-8');
769 $json_data = json_decode($json_str, true);
770
771 if ($json_data && isset($json_data['schema']['attrs'])) {
772 foreach ($json_data['schema']['attrs'] as &$attrs) {
773 if (array_key_exists('alt', $attrs)) {
774 $attrs['alt'] = $alt_text;
775 }
776 }
777 unset($attrs);
778 $new_json = json_encode($json_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
779 return 'data-et-multi-view="' . str_replace('"', '&quot;', $new_json) . '"';
780 }
781 return $mv_matches[0];
782 },
783 $new_img
784 );
785 }
786
787 $html = str_replace($original_img, $new_img, $html);
788 }
789 }
790 }
791
792 return $html;
793 }
794
795 # heading substitutions
796 function do_heading_body_substitutions($heading_data){
797
798 # loop all data
799 foreach($heading_data AS $okey => $heading){
800
801 # find all occurences of the heading type
802 $occurences = $this->dom->find($heading['type']);
803
804 # loop all occurences
805 foreach ($occurences as $ikey => $heading_old) {
806
807 # get the header text
808 $text = $heading_old->text();
809
810 # check matching text
811 if(trim($heading['current_value'] ?? '') == trim($text ?? '')){
812
813 # set the text
814 $heading_old->innertext = $heading['recommended_value'];
815
816 }
817 }
818
819 }
820
821 # save and reload the dom
822 $this->save_reload();
823 }
824
825 # link replacements
826 function do_link_body_substitutions($swap_data){
827
828 # find all links in the body
829 $links = $this->dom->find('a');
830
831 # loop all links check if we have them in swap data
832 foreach ($links as $key => $link) {
833
834 # check if link matches
835 if(!empty($swap_data[$link->href])){
836
837 # replace the link
838 $link->href = $swap_data[$link->href];
839 }
840
841 }
842 }
843
844 /**
845 * Add rel="nofollow" attribute to external links
846 * Works for both buffer and HTTP rendering methods
847 * Only processes links that don't already have rel="nofollow"
848 */
849 function add_nofollow_to_external_links(){
850
851 # find all links in the body
852 $links = $this->dom->find('a');
853
854 # get home URL for comparison
855 $home_url = rtrim(home_url(), '/');
856 $home_url_lower = strtolower($home_url);
857
858 # loop through all links
859 foreach ($links as $key => $link) {
860
861 # get the href attribute
862 $href = $link->href ?? '';
863
864 # skip if href is empty
865 if (empty($href)) {
866 continue;
867 }
868
869 # check if link is external
870 if ($this->is_external_link($href, $home_url, $home_url_lower)) {
871 # get existing rel attribute
872 $existing_rel = $link->rel ?? '';
873
874 # check if nofollow already exists
875 if (empty($existing_rel)) {
876 # no rel attribute, add nofollow
877 $link->rel = 'nofollow';
878 } elseif (strpos($existing_rel, 'nofollow') === false) {
879 # rel exists but nofollow is not present, add it
880 $link->rel = trim($existing_rel . ' nofollow');
881 }
882 # if nofollow already exists, do nothing
883 }
884 }
885
886 # Note: No need to call save_reload() here - it's called at the end of process_html_directly()
887 # This avoids redundant DOM save/reload operations and improves performance
888 }
889
890 /**
891 * Check if a URL is external
892 *
893 * @param string $url The URL to check
894 * @param string $home_url The home URL without trailing slash
895 * @param string $home_url_lower Lowercase version of home URL
896 * @return bool True if external, false if internal
897 */
898 private function is_external_link($url, $home_url, $home_url_lower) {
899 # Empty or anchor-only links are internal
900 if (empty($url) || $url === '#' || strpos($url, '#') === 0) {
901 return false;
902 }
903
904 # Relative URLs (starting with /) are internal
905 if (strpos($url, '/') === 0 && strpos($url, '//') !== 0) {
906 return false;
907 }
908
909 # Check if URL starts with home URL (case-insensitive)
910 $url_lower = strtolower($url);
911 if (strpos($url_lower, $home_url_lower) === 0) {
912 return false;
913 }
914
915 # If it's a protocol-relative URL (//example.com), check if it matches our domain
916 if (strpos($url, '//') === 0) {
917 $parsed_home = parse_url($home_url);
918 $parsed_link = parse_url($url);
919
920 if (isset($parsed_home['host']) && isset($parsed_link['host'])) {
921 if (strtolower($parsed_home['host']) === strtolower($parsed_link['host'])) {
922 return false;
923 }
924 }
925 }
926
927 # All other URLs are considered external
928 return true;
929 }
930
931 /**
932 * Add target="_blank" attribute to external links
933 * Works for both buffer and HTTP rendering methods
934 * Only processes links that don't already have a target attribute
935 */
936 function add_target_blank_to_external_links(){
937
938 # find all links in the body
939 $links = $this->dom->find('a');
940
941 # get home URL for comparison
942 $home_url = rtrim(home_url(), '/');
943 $home_url_lower = strtolower($home_url);
944
945 # loop through all links
946 foreach ($links as $key => $link) {
947
948 # get the href attribute
949 $href = $link->href ?? '';
950
951 # skip if href is empty or already has target attribute
952 if (empty($href) || !empty($link->target)) {
953 continue;
954 }
955
956 # check if link is external
957 if ($this->is_external_link($href, $home_url, $home_url_lower)) {
958 # add target="_blank" attribute
959 $link->target = '_blank';
960
961 # add rel="noopener noreferrer" for security
962 $existing_rel = $link->rel ?? '';
963 if (empty($existing_rel)) {
964 $link->rel = 'noopener noreferrer';
965 } elseif (strpos($existing_rel, 'noopener') === false) {
966 $link->rel = trim($existing_rel . ' noopener noreferrer');
967 }
968 }
969 }
970
971 # Note: No need to call save_reload() here - it's called at the end of process_html_directly()
972 # This avoids redundant DOM save/reload operations and improves performance
973 }
974
975 # body bottom html replacement code
976 function do_body_bottom_html($insert_data){
977
978 # check that data is availbale
979 if(empty($insert_data['body_bottom_html_insertion'])){
980 return;
981 }
982
983 # OPTIMIZED: Use cached body element
984 $body = $this->get_cached_element('body');
985
986 # set the link property if not empty
987 if(empty($body->outertext)){
988 return;
989 }
990
991
992 # get the tag attributes
993 $attributes_string = $this->get_tag_attributes($body);
994
995 # now do the actual html replacements
996 $body->outertext = '<body' . $attributes_string . '>' . $body->innertext . $insert_data['body_bottom_html_insertion'].'</body>';
997
998 # save the document
999 $this->save_reload();
1000 }
1001
1002 # body top html replacement
1003 function do_body_top_html($insert_data){
1004
1005 # check that data is availbale
1006 if(empty($insert_data['body_top_html_insertion'])){
1007 return;
1008 }
1009
1010 # OPTIMIZED: Use cached body element
1011 $body = $this->get_cached_element('body');
1012
1013 # get the tag attributes
1014 $attributes_string = $this->get_tag_attributes($body);
1015
1016 # now do the actual html replacements
1017 $body->outertext = '<body' . $attributes_string . '>'.$insert_data['body_top_html_insertion'].$body->innertext . '</body>';
1018 }
1019
1020 # this function does the header replacements
1021 function do_header_replacements($replacement_data){
1022
1023 # check that we have header replacements
1024 if(empty($replacement_data['header_replacements']) || !is_array($replacement_data['header_replacements'])){
1025 return;
1026 }
1027
1028 # Check for custom SEO values from MetaSync SEO Sidebar
1029 # Custom values take absolute priority over OTTO suggestions
1030 $custom_seo_title = '';
1031 $custom_seo_description = '';
1032
1033 if (function_exists('is_singular') && is_singular()) {
1034 $post_id = get_the_ID();
1035 if ($post_id) {
1036 $custom_seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
1037 $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
1038 }
1039 }
1040
1041 # now lets do the replacement work
1042 foreach($replacement_data['header_replacements'] AS $key => $data){
1043
1044 # skip cases where type is not specified
1045 if(empty($data['type'])){
1046 continue;
1047 }
1048
1049 # handle title - skip if custom SEO title exists or no value
1050 if($data['type'] == 'title'){
1051 if (!empty($custom_seo_title)) {
1052 # Skip title replacement - custom SEO title takes priority
1053 continue;
1054 }
1055
1056 # Skip if OTTO has no title value
1057 if (empty(trim($data['recommended_value'] ?? $data['value'] ?? ''))) {
1058 continue;
1059 }
1060
1061 # handle the title logic
1062 $this->replace_title($data);
1063
1064 #
1065 continue;
1066 }
1067
1068 # handle canonical links
1069 if($data['type'] == 'link' && $data['rel'] === 'canonical'){
1070
1071 # Protect manually-set canonical from OTTO override
1072 if (function_exists('is_singular') && is_singular()) {
1073 $post_id = get_the_ID();
1074 if ($post_id) {
1075 $custom_canonical = get_post_meta($post_id, '_metasync_canonical_url', true);
1076 if (empty($custom_canonical)) {
1077 $custom_canonical = get_post_meta($post_id, 'meta_canonical', true);
1078 // Handle legacy array values
1079 if (is_array($custom_canonical)) {
1080 $custom_canonical = reset($custom_canonical) ?: '';
1081 }
1082 }
1083 if (!empty($custom_canonical)) {
1084 # Manual canonical takes priority — skip OTTO override
1085 continue;
1086 }
1087 }
1088 }
1089
1090 # find the cannonical dom element
1091 $link = $this->dom->find('link[rel="canonical"]', 0);
1092
1093 # set the link property if not empty
1094 if(!empty($link->href)){
1095 $link->href = $data['recommended_value'] ?? $link->href;
1096 }
1097
1098 #
1099 continue;
1100 }
1101
1102 # work on other elemenets not titlte
1103 $this->handle_meta_element($data);
1104 }
1105 }
1106
1107 # function to handle meta elements other than title
1108 function handle_meta_element($data){
1109
1110 # Skip if OTTO has no value to set - prevents overwriting existing tags (e.g. Yoast)
1111 # with empty content when OTTO has no recommendation for this meta field
1112 $recommended_value = $data['recommended_value'] ?? $data['value'] ?? '';
1113 if (empty(trim($recommended_value))) {
1114 return;
1115 }
1116
1117 # Check if this is a description meta tag and if custom description exists
1118 # Custom values take absolute priority over OTTO suggestions
1119 $name = $data['name'] ?? false;
1120 $property = $data['property'] ?? false;
1121
1122 # Protect manually-set robots meta from OTTO override
1123 # If the user set noindex via Common Robots Meta or meta_robots, honour it
1124 if (!empty($name) && $name === 'robots') {
1125 if (function_exists('is_singular') && is_singular()) {
1126 $post_id = get_the_ID();
1127 if ($post_id) {
1128 $manual_robots = get_post_meta($post_id, 'meta_robots', true);
1129 if (!empty($manual_robots) && stripos($manual_robots, 'noindex') !== false) {
1130 # Manual noindex takes priority — skip OTTO override
1131 return;
1132 }
1133 $common_robots = get_post_meta($post_id, 'metasync_common_robots', true);
1134 if (is_array($common_robots) && !empty($common_robots['noindex'])) {
1135 # Common Robots Meta has noindex checked — skip OTTO override
1136 return;
1137 }
1138 }
1139 }
1140 }
1141
1142 # Check for custom SEO description
1143 if (!empty($name) && $name === 'description') {
1144 if (function_exists('is_singular') && is_singular()) {
1145 $post_id = get_the_ID();
1146 if ($post_id) {
1147 $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
1148 if (!empty($custom_seo_description)) {
1149 # Custom description exists, skip OTTO's suggestion
1150 return;
1151 }
1152 }
1153 }
1154 }
1155
1156 # extract property value
1157 $property = $data['property'] ?? false;
1158
1159 # extract name value
1160 $name = $data['name'] ?? false;
1161
1162 # set the selector
1163 $meta_selector = '';
1164
1165 # extend selector
1166 if(!empty($name)){
1167 $meta_selector .= 'meta[name="' . trim($name) . '"]';
1168 }
1169
1170 # extent if property is defined
1171 if(!empty($property)){
1172 if (!empty($meta_selector)) {
1173 $meta_selector .= ',';
1174 }
1175 $meta_selector .= 'meta[property="' . trim($property) . '"]';
1176 }
1177
1178 # find the meta gat in the dom
1179 $meta_tag = $this->dom->find($meta_selector, 0);
1180
1181 # if tag not exists add it
1182 if(empty($meta_tag)){
1183 if($data['type'] == 'meta'){
1184
1185 # get the attribute
1186 $attribute = $property ? 'property' : 'name';
1187
1188 # call the create metatag function
1189 $result = $this->create_metatag($attribute, $data);
1190 }
1191
1192 # return after creation
1193 return;
1194 }
1195
1196 # CRITICAL FIX: Clear cache and get fresh reference
1197 # Preserve 'imgs' key for later use by handle_images()
1198 $preserved_imgs = $this->cached_elements['imgs'] ?? null;
1199 $this->cached_elements = [];
1200 if ($preserved_imgs !== null) {
1201 $this->cached_elements['imgs'] = $preserved_imgs;
1202 }
1203
1204 # Get fresh meta tag reference using same selector
1205 $meta_tag_fresh = $this->dom->find($meta_selector, 0);
1206
1207 if ($meta_tag_fresh) {
1208 # Use outertext for replacement
1209 $new_value = htmlspecialchars($data['recommended_value'] ?? '', ENT_QUOTES, 'UTF-8');
1210
1211 # Determine attribute name
1212 $attr_name = !empty($data['name']) ? 'name' : 'property';
1213 $attr_value = !empty($data['name']) ? $data['name'] : ($data['property'] ?? '');
1214
1215 # Build new meta tag
1216 $meta_tag_fresh->outertext = '<meta ' . $attr_name . '="' . htmlspecialchars($attr_value, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
1217 }
1218
1219
1220 }
1221
1222 # function to handle the page title
1223 function replace_title($title_data){
1224
1225 # find the title
1226 $title = $this->dom->find('title', 0) ?? false;
1227
1228 # if none
1229 if($title === false){
1230 return $this->create_title($title_data);
1231 }
1232
1233 # CRITICAL FIX: Clear element cache and get fresh reference
1234 # Preserve 'imgs' key for later use by handle_images()
1235 $preserved_imgs = $this->cached_elements['imgs'] ?? null;
1236 $this->cached_elements = [];
1237 if ($preserved_imgs !== null) {
1238 $this->cached_elements['imgs'] = $preserved_imgs;
1239 }
1240
1241 # Get fresh title element reference
1242 $title_fresh = $this->dom->find('title', 0);
1243
1244 if ($title_fresh) {
1245 # Use outertext for replacement
1246 $new_value = htmlspecialchars($title_data['recommended_value'], ENT_QUOTES, 'UTF-8');
1247 $title_fresh->outertext = '<title>' . $new_value . '</title>';
1248 }
1249
1250 # Don't save_reload here - will happen at the end
1251 # $this->save_reload();
1252 }
1253
1254 # Function to create a title when it's missing
1255 function create_title($title_data) {
1256
1257 # Find the <head> tag
1258 $head = $this->dom->find('head', 0);
1259
1260 # Construct the <title> tag HTML
1261 $title_html = '<title>' . htmlspecialchars($title_data['recommended_value'], ENT_QUOTES) . '</title>';
1262
1263 if (empty($head)) {
1264 return false;
1265 }
1266
1267 # Extract existing attributes of the <head> tag
1268 $attributes = [];
1269
1270 # get the tag attributes
1271 $tag_attributes = $head->getAllAttributes();
1272
1273 # loop all attributes
1274 foreach ($tag_attributes as $key => $value) {
1275
1276 if ($value == 1) {
1277
1278 # Handle boolean attributes
1279 $attributes[] = htmlspecialchars($key, ENT_QUOTES);
1280 } else {
1281
1282 # Handle attributes with values
1283 $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
1284 }
1285 }
1286
1287 # Convert attributes array to a string
1288 $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : '';
1289
1290 # Rebuild the <head> tag, inserting the <title> at the beginning
1291 $head->outertext = '<head' . $attributes_string . '>' . $title_html . $head->innertext . '</head>';
1292
1293 # save and reload DOM
1294 $this->save_reload();
1295 }
1296
1297 # function to create meta tag if none existss
1298 function create_metatag($attribute, $data){
1299
1300 # Find the <head> tag
1301 $head = $this->dom->find('head', 0);
1302
1303 # Construct the meta tag HTML (no spaces around = for standard HTML and regex compatibility)
1304 $meta_tag = '<meta '.$attribute.'="'.htmlspecialchars($data[$attribute], ENT_QUOTES, 'UTF-8').'" content="'.htmlspecialchars($data['recommended_value'], ENT_QUOTES, 'UTF-8').'">';
1305
1306 if (empty($head)) {
1307 return false;
1308 }
1309
1310 # Extract existing attributes of the <head> tag
1311 $attributes = [];
1312
1313 # get the tag attributes
1314 $tag_attributes = $head->getAllAttributes();
1315
1316 # loop all attributes
1317 foreach ($tag_attributes as $key => $value) {
1318
1319 if ($value == 1) {
1320
1321 # Handle boolean attributes
1322 $attributes[] = htmlspecialchars($key, ENT_QUOTES);
1323 } else {
1324
1325 # Handle attributes with values
1326 $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
1327 }
1328 }
1329
1330 # Convert attributes array to a string
1331 $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : '';
1332
1333 # Rebuild the <head> tag, inserting the <title> at the beginning
1334 $head->outertext = '<head' . $attributes_string . '>' . $meta_tag . $head->innertext . '</head>';
1335
1336 # save and reload DOM
1337 $this->save_reload();
1338 }
1339
1340 /**
1341 * Remove duplicate <title> tags from HTML, keeping the first (OTTO's) value.
1342 *
1343 * OTTO's title replacement is always applied first (limit=1 or DOM manipulation),
1344 * so the first <title> tag holds the authoritative value. If SEO plugin conflicts
1345 * produce additional <title> tags, this strips all and re-inserts one.
1346 *
1347 * @param string $html Full HTML document.
1348 * @return string HTML with at most one <title> tag.
1349 */
1350 private function deduplicate_title_tags($html) {
1351 $title_count = preg_match_all('/<title[^>]*>.*?<\/title>/is', $html, $title_matches);
1352 if ($title_count <= 1) {
1353 return $html;
1354 }
1355
1356 # Capture the first title's inner text (OTTO's replacement)
1357 preg_match('/<title[^>]*>(.*?)<\/title>/is', $html, $first_title);
1358 $authoritative_title = isset($first_title[1]) ? $first_title[1] : '';
1359
1360 # Strip all <title> tags
1361 $html = preg_replace('/<title[^>]*>.*?<\/title>/is', '', $html);
1362
1363 # Re-insert a single <title> after <head> using preg_replace_callback to prevent
1364 # backreference injection when title contains $ followed by digits (e.g. "$50 off")
1365 $title_tag = '<title>' . $authoritative_title . '</title>';
1366 $html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($title_tag) {
1367 return $m[1] . $title_tag;
1368 }, $html, 1);
1369
1370 return $html;
1371 }
1372
1373 /**
1374 * Remove duplicate OG and Twitter meta tags from HTML after OTTO processing.
1375 *
1376 * OTTO injects its tags with `data-otto-pixel` or `data-otto` attributes.
1377 * Legacy MetaSync output and third-party SEO plugins may also emit the same
1378 * OG/Twitter properties. This method runs at the buffer level — after all
1379 * sources have written their tags — and keeps only the OTTO version when
1380 * duplicates exist.
1381 *
1382 * Strategy per property (e.g. og:description):
1383 * - If OTTO tag exists (has data-otto marker) → remove all non-OTTO duplicates
1384 * - If no OTTO tag exists → keep the first occurrence, remove the rest
1385 *
1386 * @param string $html Full HTML document.
1387 * @return string HTML with at most one tag per OG/Twitter property.
1388 */
1389 private function deduplicate_og_twitter_tags($html) {
1390 # OG properties to deduplicate
1391 $og_properties = [
1392 'og:title', 'og:description', 'og:url', 'og:type',
1393 'og:locale', 'og:site_name', 'og:image',
1394 ];
1395
1396 foreach ($og_properties as $prop) {
1397 $html = $this->deduplicate_meta_by_attr($html, 'property', $prop);
1398 }
1399
1400 # Twitter names to deduplicate
1401 $twitter_names = [
1402 'twitter:title', 'twitter:description', 'twitter:card',
1403 'twitter:image', 'twitter:site',
1404 ];
1405
1406 foreach ($twitter_names as $name) {
1407 $html = $this->deduplicate_meta_by_attr($html, 'name', $name);
1408 }
1409
1410 return $html;
1411 }
1412
1413 /**
1414 * Deduplicate meta tags by a specific attribute (property= or name=).
1415 *
1416 * When duplicates exist and one carries a data-otto marker, keep only
1417 * the OTTO version. Otherwise keep the first occurrence.
1418 *
1419 * @param string $html Full HTML.
1420 * @param string $attr_name Attribute name: 'property' or 'name'.
1421 * @param string $attr_val Attribute value: e.g. 'og:title' or 'twitter:description'.
1422 * @return string
1423 */
1424 private function deduplicate_meta_by_attr($html, $attr_name, $attr_val) {
1425 $escaped = preg_quote($attr_val, '/');
1426 # Match all <meta> tags with this attribute value (both attr orderings)
1427 $pattern = '/<meta\s[^>]*' . preg_quote($attr_name, '/') . '\s*=\s*["\']' . $escaped . '["\'][^>]*\/?>/i';
1428
1429 if (preg_match_all($pattern, $html, $matches) <= 1) {
1430 return $html; # 0 or 1 — nothing to deduplicate
1431 }
1432
1433 $all_tags = $matches[0];
1434
1435 # Find the OTTO tag (has data-otto-pixel or data-otto attribute)
1436 $otto_tag = null;
1437 foreach ($all_tags as $tag) {
1438 if (stripos($tag, 'data-otto') !== false) {
1439 $otto_tag = $tag;
1440 break;
1441 }
1442 }
1443
1444 # Determine the keeper: OTTO tag if present, otherwise the first tag
1445 $keeper = $otto_tag ?: $all_tags[0];
1446
1447 # Remove all occurrences, then re-insert the keeper at the first position
1448 $first_replaced = false;
1449 $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
1450 if (!$first_replaced) {
1451 $first_replaced = true;
1452 return $keeper;
1453 }
1454 return ''; # Remove subsequent duplicates
1455 }, $html);
1456
1457 return $html;
1458 }
1459
1460 /**
1461 * Remove duplicate <link rel="canonical"> tags from HTML.
1462 *
1463 * When OTTO injects a canonical via header_html_insertion and MetaSync's
1464 * SEO output (or WordPress core) has already emitted one, keep only the
1465 * OTTO version (identified by data-otto marker). If no OTTO tag exists,
1466 * keep the first occurrence.
1467 *
1468 * @param string $html Full HTML document.
1469 * @return string HTML with at most one canonical tag.
1470 */
1471 private function deduplicate_canonical_tags($html) {
1472 $pattern = '/<link\s[^>]*rel=["\']canonical["\'][^>]*\/?>/i';
1473
1474 if (preg_match_all($pattern, $html, $matches) <= 1) {
1475 return $html;
1476 }
1477
1478 $all_tags = $matches[0];
1479
1480 $otto_tag = null;
1481 foreach ($all_tags as $tag) {
1482 if (stripos($tag, 'data-otto') !== false) {
1483 $otto_tag = $tag;
1484 break;
1485 }
1486 }
1487
1488 $keeper = $otto_tag ?: $all_tags[0];
1489
1490 $first_replaced = false;
1491 $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
1492 if (!$first_replaced) {
1493 $first_replaced = true;
1494 return $keeper;
1495 }
1496 return '';
1497 }, $html);
1498
1499 return $html;
1500 }
1501
1502 /**
1503 * Deduplicate JSON-LD schema blocks.
1504 *
1505 * When OTTO and a third-party SEO plugin both inject <script type="application/ld+json">
1506 * blocks, keep OTTO's version for any @type that appears in both.
1507 * Third-party blocks whose @type is not covered by OTTO are preserved.
1508 *
1509 * @param string $html Full HTML.
1510 * @return string
1511 */
1512 private function deduplicate_schema_tags($html) {
1513 // Find all JSON-LD script blocks
1514 $pattern = '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2[^>]*>\s*([\s\S]*?)<\/script>/i';
1515 if (preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) <= 1) {
1516 return $html;
1517 }
1518
1519 $otto_by_type = []; // @type => decoded JSON object
1520 $third_by_type = []; // @type => decoded JSON object
1521 $otto_graph = []; // entries from OTTO @graph blocks
1522 $third_graph = []; // entries from third-party @graph blocks
1523
1524 foreach ($matches as $m) {
1525 $attrs = $m[1];
1526 $json_str = $m[3];
1527 $decoded = json_decode($json_str, true);
1528 if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
1529 continue; // skip unparseable blocks — leave them in place
1530 }
1531 $is_otto = stripos($attrs, 'data-otto') !== false;
1532
1533 if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
1534 foreach ($decoded['@graph'] as $entry) {
1535 if (!isset($entry['@type'])) continue;
1536 // JSON-LD allows @type to be a string OR an array of strings.
1537 // Rank Math routinely emits multi-typed entries (e.g. ["Person", "Organization"]).
1538 // Using an array as an offset throws a fatal on PHP 8+, so normalize to scalar.
1539 $type = is_array($entry['@type'])
1540 ? (string) reset($entry['@type'])
1541 : (string) $entry['@type'];
1542 if ($type === '') continue;
1543 if ($is_otto) {
1544 $otto_graph[$type] = $entry;
1545 } else {
1546 $third_graph[$type] = $entry;
1547 }
1548 }
1549 } elseif (isset($decoded['@type'])) {
1550 $type = is_array($decoded['@type'])
1551 ? (string) reset($decoded['@type'])
1552 : (string) $decoded['@type'];
1553 if ($type === '') continue;
1554 if ($is_otto) {
1555 $otto_by_type[$type] = $decoded;
1556 } else {
1557 $third_by_type[$type] = $decoded;
1558 }
1559 }
1560 }
1561
1562 // If OTTO provided no schema at all, nothing to deduplicate
1563 if (empty($otto_by_type) && empty($otto_graph)) {
1564 return $html;
1565 }
1566
1567 // Remove all JSON-LD blocks from HTML
1568 $html = preg_replace($pattern, '', $html);
1569
1570 // Re-insert flat (non-@graph) blocks: OTTO wins for matching @type
1571 $kept = array_merge($otto_by_type, array_diff_key($third_by_type, $otto_by_type));
1572 $rebuilt = '';
1573 foreach ($kept as $decoded) {
1574 $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
1575 wp_json_encode($decoded) . "</script>\n";
1576 }
1577
1578 // Re-insert merged @graph block (if any entries exist)
1579 $merged_graph = array_merge($third_graph, $otto_graph); // OTTO wins on duplicate @type
1580 if (!empty($merged_graph)) {
1581 $graph_obj = ['@context' => 'https://schema.org', '@graph' => array_values($merged_graph)];
1582 $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
1583 wp_json_encode($graph_obj) . "</script>\n";
1584 }
1585
1586 // Re-inject before </head>
1587 if (!empty($rebuilt)) {
1588 $html = preg_replace_callback('/(<\/head>)/i', function ($m) use ($rebuilt) {
1589 return $rebuilt . $m[1];
1590 }, $html, 1);
1591 }
1592
1593 return $html;
1594 }
1595
1596 # function to detect if current page is an AMP page
1597 function is_amp_page(){
1598
1599 # Check if URL path contains /amp/
1600 $current_url = $_SERVER['REQUEST_URI'] ?? '';
1601 if (strpos($current_url, '/amp/') !== false) {
1602 return true;
1603 }
1604
1605 # Check if URL ends with /amp
1606 if (preg_match('/\/amp\/?$/', $current_url)) {
1607 return true;
1608 }
1609
1610 # Check if amp=1 query parameter is present
1611 if (isset($_GET['amp']) && $_GET['amp'] == '1') {
1612 return true;
1613 }
1614
1615 # Check for other common AMP query parameters
1616 if (isset($_GET['amp']) && !empty($_GET['amp'])) {
1617 return true;
1618 }
1619
1620 return false;
1621 }
1622
1623 # this function insterts header html to the dom
1624 function insert_header_html($data){
1625
1626 # check that we have the header html
1627 if(empty($data['header_html_insertion'])){
1628 #
1629 return;
1630 }
1631
1632 # append the/ html at the start of the header
1633 $head = $this->dom->find('head', 0);
1634
1635 if ($head) {
1636
1637 # Check if this is an AMP page - if so, don't add metasync_optimized attribute
1638 $is_amp_page = $this->is_amp_page();
1639
1640 # Append the new HTML at the start of the <head> tag
1641 # For AMP pages: use clean <head> tag without metasync_optimized attribute
1642 # For non-AMP pages: add metasync_optimized attribute to <head> tag
1643 if ($is_amp_page) {
1644 $head->outertext = '<head>' .$data['header_html_insertion']. $head->innertext . '</head>';
1645 } else {
1646 $head->outertext = '<head metasync_optimized>' .$data['header_html_insertion']. $head->innertext . '</head>';
1647 }
1648
1649 }
1650
1651 # save and reload DOM
1652 $this->save_reload();
1653 }
1654
1655 # function to forcefully remove metasync_optimized attribute from head on AMP pages
1656 function cleanup_amp_metasync_attribute(){
1657
1658 # Only proceed if this is an AMP page
1659 if (!$this->is_amp_page()) {
1660 return;
1661 }
1662
1663 # Find the head tag
1664 $head = $this->dom->find('head', 0);
1665
1666 if (!$head) {
1667 return;
1668 }
1669
1670 # Check if head has metasync_optimized attribute
1671 $head_html = $head->outertext;
1672
1673 # If metasync_optimized attribute is found, remove it
1674 if (strpos($head_html, 'metasync_optimized') !== false) {
1675
1676 # Remove the metasync_optimized attribute from the head tag
1677 # This handles various formats: <head metasync_optimized>, <head metasync_optimized=""> etc.
1678 $cleaned_head_html = preg_replace('/\s*metasync_optimized(?:="[^"]*")?/', '', $head_html);
1679
1680 # Update the head element
1681 $head->outertext = $cleaned_head_html;
1682
1683 }
1684 }
1685
1686 /**
1687 * PERFORMANCE OPTIMIZATION: Pre-cache commonly accessed DOM elements
1688 * Reduces 8-10 full DOM traversals to 1 initial traversal
1689 * Call this once after loading HTML, before processing
1690 */
1691 private function cache_elements() {
1692 if (!$this->dom) {
1693 return;
1694 }
1695
1696 # Cache all commonly accessed elements in one pass
1697 $this->cached_elements = [
1698 'html' => $this->dom->find('html', 0),
1699 'head' => $this->dom->find('head', 0),
1700 'body' => $this->dom->find('body', 0),
1701 'footer' => $this->dom->find('footer', 0),
1702 'title' => $this->dom->find('title', 0),
1703 'imgs' => $this->dom->find('img'),
1704 'links' => $this->dom->find('a'),
1705 'canonical' => $this->dom->find('link[rel="canonical"]', 0),
1706 ];
1707 }
1708
1709 /**
1710 * Get cached element by key, with fallback to DOM find
1711 * @param string $key Element key from cache
1712 * @param mixed $fallback Fallback value if not cached
1713 * @return mixed Cached element or fallback
1714 */
1715 private function get_cached_element($key, $fallback = null) {
1716 return $this->cached_elements[$key] ?? $fallback;
1717 }
1718
1719 # this function saves are reloads the dom for modifications to avoid conflict
1720 function save_reload(){
1721
1722 # PERFORMANCE OPTIMIZATION: Skip reload if deferred
1723 # This reduces multiple serialize/deserialize cycles to just one final reload
1724 if ($this->deferred_reload) {
1725 return;
1726 }
1727
1728 # Cleanup metasync_optimized attribute on AMP pages before saving
1729 $this->cleanup_amp_metasync_attribute();
1730
1731 # DISABLED: Cache file creation temporarily disabled
1732
1733 # if(file_put_contents($this->html_file, $this->dom)){
1734
1735 # load the modified file to the DOM
1736 # $this->dom = new HtmlDocument($this->html_file );
1737 # }
1738
1739 # this code is to be replaced in future
1740 # reson for adding is to prevent caching logged in user pages
1741 # why not just skip saving? it broke the DOM Library
1742 # check user is logged in clear the file
1743
1744 # if(is_user_logged_in()) {
1745 # unlink($this->html_file);
1746 # }
1747
1748 # MEMORY-BASED RELOAD: Instead of file operations, reload DOM from current HTML string
1749 # This prevents DOM breaking while avoiding cache file creation
1750 if($this->dom){
1751 # Get current DOM as HTML string
1752 $current_html = $this->dom->save();
1753
1754 # Reload DOM from the HTML string to refresh internal state
1755 # This replaces the file save/reload cycle that SimpleHtmlDOM expects
1756 $current_html = $this->sanitize_text_less_than($current_html);
1757 $this->dom->load($current_html, true, false);
1758
1759 # Force UTF-8 charset after reload to preserve emojis
1760 $this->dom->_charset = 'UTF-8';
1761 $this->dom->_target_charset = 'UTF-8';
1762 }
1763
1764 }
1765
1766 /**
1767 * Process HTML directly without HTTP request (for buffer approach)
1768 * This is the FAST path - eliminates the internal wp_remote_get call
1769 *
1770 * CRITICAL: This method is called from the output buffer callback.
1771 * If it fails, we must return false so the original HTML can be used.
1772 *
1773 * @param string $html The raw HTML captured from output buffer
1774 * @param array $replacement_data OTTO suggestions/replacement data
1775 * @return HtmlDocument|false Modified DOM or false on failure
1776 * @since 2.6.0
1777 */
1778 function process_html_directly($html, $replacement_data) {
1779 try {
1780 # Validate inputs
1781 if (empty($html) || empty($replacement_data) || !is_array($replacement_data)) {
1782 return false;
1783 }
1784
1785 # Validate HTML is actual HTML content
1786 if (stripos($html, '<html') === false && stripos($html, '<!DOCTYPE') === false) {
1787 return false;
1788 }
1789
1790 # Remove XML declaration if present
1791 $html = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html);
1792
1793 # Escape bare < in text content before DOM parsing
1794 $html = $this->sanitize_text_less_than($html);
1795
1796 # Load HTML into DOM
1797 $this->dom->load($html, true, false);
1798
1799 # Force UTF-8 charset to preserve emojis and special characters
1800 $this->dom->_charset = 'UTF-8';
1801 $this->dom->_target_charset = 'UTF-8';
1802
1803 # PERFORMANCE OPTIMIZATION: Pre-cache commonly accessed DOM elements
1804 # This eliminates 8-10 full DOM traversals per page
1805 $this->cache_elements();
1806
1807 # PERFORMANCE OPTIMIZATION: Enable deferred reload to skip intermediate reloads
1808 # This reduces 6-10 DOM serialize/deserialize cycles to just 1 final reload
1809 $this->deferred_reload = true;
1810
1811 # Apply blocking flags if available (for SEO plugin coordination)
1812 if (!empty($replacement_data['_otto_blocking'])) {
1813 $block_title = $replacement_data['_otto_blocking']['block_title'] ?? false;
1814 $block_description_tags = $replacement_data['_otto_blocking']['block_description_tags'] ?? [];
1815
1816 # Remove SEO plugin meta tags if OTTO is providing them
1817 if ($block_title || !empty($block_description_tags)) {
1818 $this->remove_conflicting_seo_tags($block_title, $block_description_tags);
1819 }
1820 }
1821
1822 # Apply all OTTO modifications (same as handle_route_html but without HTTP fetch)
1823
1824 # COMPATIBILITY FIX: Transform 'value' to 'recommended_value' if needed
1825 # Some API versions return 'value' instead of 'recommended_value'
1826 if (!empty($replacement_data['header_replacements'])) {
1827 foreach ($replacement_data['header_replacements'] as &$item) {
1828 if (isset($item['value']) && !isset($item['recommended_value'])) {
1829 $item['recommended_value'] = $item['value'];
1830 }
1831 }
1832 unset($item); // Break reference
1833 }
1834
1835 # 1. Header HTML insertion
1836 $this->insert_header_html($replacement_data);
1837
1838 # 2. Header replacements (title, meta, canonical)
1839 $this->do_header_replacements($replacement_data);
1840
1841 # 3. Body replacements (top, bottom, substitutions)
1842 $this->do_body_replacements($replacement_data);
1843
1844 # 4. Footer HTML insertion
1845 $this->do_footer_html_insertion($replacement_data);
1846
1847 # 5. Final cleanup for AMP pages
1848 $this->cleanup_amp_metasync_attribute();
1849
1850 # CRITICAL FIX: Clear cached elements and force DOM to refresh
1851 $this->deferred_reload = false;
1852 $this->cached_elements = []; // Clear our custom cache
1853
1854 # Clear SimpleHtmlDom's internal cache
1855 if (method_exists($this->dom, 'clear')) {
1856 $this->dom->clear();
1857 }
1858
1859
1860 # Try getting HTML via root element instead of save()
1861 $root = $this->dom->root;
1862 if ($root && isset($root->outertext)) {
1863 $result_html = $root->outertext;
1864 } else {
1865 # Fallback to save() method
1866 $result_html = $this->dom->save();
1867 }
1868
1869 # DEBUG: Check if DOM changes persisted
1870 if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $result_html, $matches)) {
1871 }
1872
1873 # Apply header replacements manually via string replacement
1874 if (!empty($replacement_data['header_replacements'])) {
1875
1876 foreach ($replacement_data['header_replacements'] as $idx => $item) {
1877 $type = $item['type'] ?? '';
1878 $value = $item['recommended_value'] ?? $item['value'] ?? '';
1879
1880
1881 if (empty($value)) {
1882 continue;
1883 }
1884
1885 if ($type === 'title') {
1886 # Replace title tag if present; otherwise insert (e.g. when Yoast was blocked and no <title> was output)
1887 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
1888 $title_tag = '<title>' . $new_value . '</title>';
1889 $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
1890 return $title_tag;
1891 }, $result_html, 1);
1892 if ($replaced === $result_html && strpos($result_html, '<title') === false) {
1893 # No <title> in document (common when Yoast is blocked) — insert after <head>
1894 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($title_tag) {
1895 return $m[1] . "\n" . $title_tag;
1896 }, $result_html, 1);
1897 } else {
1898 $result_html = $replaced;
1899 }
1900 } elseif ($type === 'meta') {
1901 $name = $item['name'] ?? '';
1902 $property = $item['property'] ?? '';
1903 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
1904
1905 if (!empty($name)) {
1906 # MEMORY OPTIMIZED: Count meta tags without storing all matches
1907 # preg_match_all requires $matches, so we pass it but unset immediately
1908 # Use \s*=\s* to match attributes with or without spaces around =
1909 $before_count = preg_match_all('/<meta[^>]+name\s*=\s*["\']' . preg_quote($name, '/') . '["\'][^>]*>/i', $result_html, $before_matches);
1910
1911 # Free memory immediately after use
1912 unset($before_matches);
1913
1914 # IMPROVED FIX: Remove ALL existing meta tags with this name
1915 # This pattern matches ANY meta tag with name="X" regardless of:
1916 # - Attribute order (name before/after content)
1917 # - Additional attributes (id, class, data-*, etc.)
1918 # - Quote style (single or double quotes)
1919 # - Whitespace variations
1920 # The pattern uses [^>]* to match ANY characters until the closing >
1921 $removed_count = preg_replace_callback(
1922 '/<meta\s+[^>]*name\s*=\s*["\']' . preg_quote($name, '/') . '["\'][^>]*>/i',
1923 function($match) {
1924 return ''; // Remove the tag
1925 },
1926 $result_html,
1927 -1, // Remove all occurrences
1928 $total_removed
1929 );
1930
1931 # Update the HTML with removed tags
1932 if ($total_removed > 0) {
1933 $result_html = $removed_count;
1934 }
1935
1936
1937 # MEMORY OPTIMIZED: Verify removal - count again but free memory immediately
1938 $after_count = preg_match_all('/<meta[^>]+name\s*=\s*["\']' . preg_quote($name, '/') . '["\'][^>]*>/i', $result_html, $after_matches);
1939 unset($after_matches);
1940
1941 # Now insert ONE new meta tag at the TOP of <head>
1942 $replacement = '<meta name="' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '" data-otto="true">';
1943 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
1944 return $m[1] . "\n" . $replacement;
1945 }, $result_html, 1);
1946 } elseif (!empty($property)) {
1947 # Replace meta property tag
1948 $pattern = '/<meta\s+property\s*=\s*["\']' . preg_quote($property, '/') . '["\']\s+content\s*=\s*["\'][^"\']*["\']\s*\/?>/i';
1949 $replacement = '<meta property="' . htmlspecialchars($property, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
1950
1951 if (preg_match($pattern, $result_html)) {
1952 $result_html = preg_replace_callback($pattern, function ($m) use ($replacement) {
1953 return $replacement;
1954 }, $result_html, 1);
1955 } else {
1956 # Meta tag doesn't exist, insert it in head
1957 $result_html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($replacement) {
1958 return $m[1] . "\n" . $replacement;
1959 }, $result_html, 1);
1960 }
1961 }
1962 } elseif ($type === 'h1' || $type === 'heading') {
1963 # Replace first H1 tag, preserving attributes
1964 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
1965 $result_html = preg_replace_callback(
1966 '/<h1([^>]*)>.*?<\/h1>/is',
1967 function ($m) use ($new_value) {
1968 return '<h1' . $m[1] . '>' . $new_value . '</h1>';
1969 },
1970 $result_html,
1971 1
1972 );
1973 }
1974 }
1975 }
1976
1977 # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it
1978 if (!empty($replacement_data['header_html_insertion'])) {
1979 $header_html_check = trim($replacement_data['header_html_insertion']);
1980 if (strpos($result_html, $header_html_check) === false) {
1981 $header_html_insertion = preg_replace(
1982 '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
1983 '<script$1type=$2application/ld+json$2 data-otto="true"',
1984 $replacement_data['header_html_insertion']
1985 );
1986 $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
1987 # Insert before </head>
1988 $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1);
1989 }
1990 }
1991
1992 # When Otto block has a title: ensure only ONE <title> (remove non-Otto, then keep first only)
1993 if (!empty($replacement_data['header_html_insertion']) && stripos($replacement_data['header_html_insertion'], '<title') !== false) {
1994 $result_html = preg_replace(
1995 '/<title(?![^>]*data-otto-pixel\s*=\s*["\']dynamic-seo["\'])[^>]*>.*?<\/title>\s*/is',
1996 '',
1997 $result_html
1998 );
1999 $first = true;
2000 $result_html = preg_replace_callback(
2001 '/<title[^>]*>.*?<\/title>\s*/is',
2002 function ($m) use (&$first) {
2003 if ($first) {
2004 $first = false;
2005 return $m[0];
2006 }
2007 return '';
2008 },
2009 $result_html
2010 );
2011 }
2012
2013 # Apply body top HTML insertion — only if DOM insertion didn't already apply it
2014 if (!empty($replacement_data['body_top_html_insertion'])) {
2015 $body_top_check = trim($replacement_data['body_top_html_insertion']);
2016 if (strpos($result_html, $body_top_check) === false) {
2017 $body_top_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_top_html_insertion']);
2018 # Insert after <body>
2019 $result_html = preg_replace('/(<body[^>]*>)/i', '$1' . "\n" . $body_top_html, $result_html, 1);
2020 }
2021 }
2022
2023 # Apply body bottom HTML insertion — only if DOM insertion didn't already apply it
2024 if (!empty($replacement_data['body_bottom_html_insertion'])) {
2025 $body_bottom_check = trim($replacement_data['body_bottom_html_insertion']);
2026 if (strpos($result_html, $body_bottom_check) === false) {
2027 $body_bottom_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_bottom_html_insertion']);
2028 # Insert before </body>
2029 $result_html = preg_replace('/(<\/body>)/i', $body_bottom_html . "\n" . '$1', $result_html, 1);
2030 }
2031 }
2032
2033 # Apply footer HTML insertion
2034 if (!empty($replacement_data['footer_html_insertion'])) {
2035 $footer_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['footer_html_insertion']);
2036 # Insert before </html>
2037 $result_html = preg_replace('/(<\/html>)/i', $footer_html . "\n" . '$1', $result_html, 1);
2038 }
2039
2040 # CRITICAL FIX: Apply image alt text manually via string replacement
2041 # DOM changes don't persist, must use string replacement
2042 $result_html = $this->apply_image_alt_text_via_string($result_html, $replacement_data);
2043
2044 # String-based heading fallback for body_substitutions
2045 # DOM changes via SimpleHtmlDom don't persist on Divi/page-builder sites
2046 if (!empty($replacement_data['body_substitutions']['headings']) && is_array($replacement_data['body_substitutions']['headings'])) {
2047 foreach ($replacement_data['body_substitutions']['headings'] as $heading) {
2048 if (empty($heading['type']) || empty($heading['current_value']) || empty($heading['recommended_value'])) {
2049 continue;
2050 }
2051
2052 $heading_type = preg_quote($heading['type'], '/');
2053 $current_value = trim(preg_replace('/\s+/', ' ', html_entity_decode($heading['current_value'], ENT_QUOTES, 'UTF-8')));
2054 $recommended_value = htmlspecialchars($heading['recommended_value'], ENT_QUOTES, 'UTF-8');
2055
2056 $result_html = preg_replace_callback(
2057 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
2058 function ($m) use ($current_value, $recommended_value) {
2059 $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
2060 if ($inner_text === $current_value) {
2061 return $m[1] . $recommended_value . $m[3];
2062 }
2063 return $m[0];
2064 },
2065 $result_html,
2066 -1
2067 );
2068 }
2069 }
2070
2071 # DEBUG: Check what we're returning
2072 if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $result_html, $matches)) {
2073 }
2074
2075 # FINAL VERIFICATION: Count meta descriptions in returned HTML
2076 $final_meta_count = preg_match_all('/<meta[^>]+name=["\']description["\'][^>]*>/i', $result_html, $final_meta_matches);
2077 if ($final_meta_count > 0) {
2078 foreach ($final_meta_matches[0] as $idx => $meta) {
2079 }
2080 }
2081
2082 if ($final_meta_count > 1) {
2083 }
2084
2085 # AGGRESSIVE DUPLICATE REMOVAL: Remove any meta description without data-otto marker
2086 # Only runs when OTTO has actually inserted its own description (data-otto marker present)
2087 # This prevents stripping Yoast/plugin descriptions when OTTO has no description to replace
2088
2089 $removal_count = 0;
2090 $otto_has_description = (bool) preg_match('/<meta[^>]*name\s*=\s*["\']description["\'][^>]*data-otto[^>]*>/i', $result_html);
2091 if ($otto_has_description) {
2092 $result_html = preg_replace_callback(
2093 '/<meta\s+([^>]*name\s*=\s*["\']description["\'][^>]*)>/i',
2094 function($match) use (&$removal_count) {
2095 # Keep only if it has data-otto="true"
2096 if (stripos($match[1], 'data-otto') !== false) {
2097 return $match[0]; // Keep OTTO's meta tag
2098 }
2099 # Remove any other meta description
2100 $removal_count++;
2101 return '';
2102 },
2103 $result_html
2104 );
2105 }
2106
2107 # DEDUPLICATION: Remove duplicate <title>, OG, Twitter tags, canonical, and JSON-LD schema
2108 $result_html = $this->deduplicate_title_tags($result_html);
2109 $result_html = $this->deduplicate_og_twitter_tags($result_html);
2110 $result_html = $this->deduplicate_schema_tags($result_html);
2111 $result_html = $this->deduplicate_canonical_tags($result_html);
2112
2113 # MEMORY OPTIMIZED: Free all large objects and arrays before returning
2114 # This ensures memory is released immediately, especially important for high-traffic sites
2115 unset($final_meta_matches, $matches);
2116
2117 # Clear SimpleHtmlDom internal cache to free memory
2118 # Note: We don't unset $this->dom as the object may be reused
2119 if ($this->dom && method_exists($this->dom, 'clear')) {
2120 $this->dom->clear();
2121 }
2122
2123 # Clear element cache array
2124 $this->cached_elements = [];
2125
2126 # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
2127 if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
2128 $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
2129 }
2130
2131 $result_html = $this->restore_case_sensitive_attributes($result_html);
2132
2133 return $result_html;
2134
2135 } catch (Exception $e) {
2136 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Exception in process_html_directly - ' . $e->getMessage());
2137 return false;
2138 } catch (Error $e) {
2139 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Error in process_html_directly - ' . $e->getMessage());
2140 return false;
2141 }
2142 }
2143
2144 /**
2145 * Remove conflicting SEO plugin meta tags from DOM
2146 * Called when OTTO is providing its own title/description
2147 *
2148 * @param bool $remove_title Remove title-related tags
2149 * @param array|bool $remove_description_tags Array of specific description tag selectors to remove, or false/empty array for none
2150 * @since 2.6.0
2151 */
2152 private function remove_conflicting_seo_tags($remove_title = false, $remove_description_tags = []) {
2153 if (!$this->dom) {
2154 return;
2155 }
2156
2157 # Find head element
2158 $head = $this->dom->find('head', 0);
2159 if (!$head) {
2160 return;
2161 }
2162
2163 # Check for custom SEO values from MetaSync SEO Sidebar
2164 # Custom values take absolute priority over OTTO suggestions
2165 $has_custom_title = false;
2166 $has_custom_description = false;
2167
2168 if (function_exists('is_singular') && is_singular()) {
2169 $post_id = get_the_ID();
2170 if ($post_id) {
2171 $custom_seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
2172 $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
2173 $has_custom_title = !empty($custom_seo_title);
2174 $has_custom_description = !empty($custom_seo_description);
2175 }
2176 }
2177
2178 # Handle description tags - only remove specific tags that Otto is providing
2179 if (!empty($remove_description_tags) && is_array($remove_description_tags) && !$has_custom_description) {
2180 # Remove only the specific description tags that Otto is providing
2181 foreach ($remove_description_tags as $selector) {
2182 $tags = $this->dom->find($selector);
2183 foreach ($tags as $tag) {
2184 # Remove the tag
2185 $tag->outertext = '';
2186 }
2187 }
2188 }
2189
2190 if ($remove_title && !$has_custom_title) {
2191 # Remove Open Graph and Twitter title tags (keep main <title>)
2192 $title_selectors = [
2193 'meta[property=og:title]',
2194 'meta[name=twitter:title]',
2195 ];
2196
2197 foreach ($title_selectors as $selector) {
2198 $tags = $this->dom->find($selector);
2199 foreach ($tags as $tag) {
2200 $tag->outertext = ''; # Remove the tag
2201 }
2202 }
2203 }
2204
2205 # Save changes
2206 $this->save_reload();
2207 }
2208
2209
2210 }