PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.5.23
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.5.23
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.5.23, at otto/Otto_html_class.php

1,710 lines 66.8 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 function __construct($otto_uuid){
40
41 # set the site uuid using the provided string
42 $this->site_uuid = $otto_uuid;
43
44 # Use endpoint manager if available, otherwise fallback to production
45 if (class_exists('Metasync_Endpoint_Manager')) {
46 $this->otto_end_point = Metasync_Endpoint_Manager::get_endpoint('OTTO_URL_DETAILS');
47 } else {
48 $this->otto_end_point = 'https://sa.searchatlas.com/api/v2/otto-url-details';
49 }
50
51 # laod the simple html dom parser with UTF-8 charset to handle special characters
52 $this->dom = new HtmlDocument(null, true, true, 'UTF-8', false);
53 }
54
55 /**
56 * Check Route Method
57 * @param route : The route to check
58 * @param path : The path of the html file to save
59 */
60 function process_route($route, $file_path){
61
62 # Construct the full endpoint URL with query parameters
63 $url_with_params = add_query_arg(
64 [
65 'url' => $route,
66 'uuid' => $this->site_uuid,
67 ],
68 $this->otto_end_point
69 );
70
71 # PERFORMANCE FIX: Add timeout to prevent blocking
72 $args = array(
73 'timeout' => 5, // 5 second max timeout (allow time for redirects)
74 'redirection' => 5, // CRITICAL FIX: Allow redirects (API returns 301)
75 'user-agent' => 'MetaSync-OTTO-SSR/2.0',
76 'sslverify' => true
77 );
78
79 # Perform the GET request with timeout
80 $response = wp_remote_get($url_with_params, $args);
81
82 # Check for errors
83 if (is_wp_error($response)) {
84 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API call failed - ' . $response->get_error_message());
85 return false;
86 }
87
88 # get the response body
89 $body = wp_remote_retrieve_body($response);
90
91 # Get the response code
92 $response_code = wp_remote_retrieve_response_code($response);
93
94 # if no change data skip
95 if (empty($body) || $response_code !== 200){
96 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API returned empty or non-200. Code: ' . $response_code);
97 return false;
98 }
99
100 # set the html file path
101 $this->html_file = $file_path;
102
103 # load change data
104 $change_data = json_decode($body, true);
105
106 # Process with the fetched data
107 return $this->process_route_with_data($route, $change_data, $file_path);
108 }
109
110 /**
111 * Process route with pre-fetched suggestions data
112 * OPTION 1: Used when data comes from transient cache
113 * @param route : The route to check
114 * @param change_data : Pre-fetched OTTO suggestions data
115 * @param path : The path of the html file to save
116 */
117 function process_route_with_data($route, $change_data, $file_path){
118
119 if (empty($change_data) || !is_array($change_data)) {
120 return false;
121 }
122
123 # set the html file path
124 $this->html_file = $file_path;
125
126 # Analyze what Otto is providing and store for conditional SEO blocking
127 $has_otto_title = false;
128 $otto_description_tags = []; // Track specific description tags Otto provides
129
130 if (!empty($change_data['header_replacements']) && is_array($change_data['header_replacements'])) {
131 foreach ($change_data['header_replacements'] as $item) {
132 if (!empty($item['type'])) {
133 # Check if Otto has title
134 if ($item['type'] == 'title' && !empty($item['recommended_value'])) {
135 $has_otto_title = true;
136 }
137 # Check if Otto has description - track specific tag types
138 if ($item['type'] == 'meta') {
139 # Check for meta[name=description]
140 if (!empty($item['name']) && $item['name'] == 'description' && !empty($item['recommended_value'])) {
141 $otto_description_tags[] = 'meta[name=description]';
142 }
143 # Check for meta[property=og:description]
144 if (!empty($item['property']) && $item['property'] == 'og:description' && !empty($item['recommended_value'])) {
145 $otto_description_tags[] = 'meta[property=og:description]';
146 }
147 # Check for meta[name=twitter:description]
148 if (!empty($item['name']) && $item['name'] == 'twitter:description' && !empty($item['recommended_value'])) {
149 $otto_description_tags[] = 'meta[name=twitter:description]';
150 }
151 }
152 }
153 }
154 }
155
156 # Check header_html_insertion for description - must have non-empty content value
157 if (!empty($change_data['header_html_insertion'])) {
158 if (preg_match('/<meta[^>]*name=["\']description["\'][^>]*content=["\']([^"\']+)["\'][^>]*>/i', $change_data['header_html_insertion'])) {
159 $otto_description_tags[] = 'meta[name=description]';
160 }
161 }
162
163 # Remove duplicates
164 $otto_description_tags = array_unique($otto_description_tags);
165
166 # Store blocking flags to pass to handle_route_html
167 # This will be added to the internal fetch URL as parameters
168 $change_data['_otto_blocking'] = array(
169 'block_title' => $has_otto_title,
170 'block_description_tags' => $otto_description_tags // Pass array of specific tags to remove
171 );
172
173 # Process the route with the suggestions data
174 return $this->handle_route_html($route, $change_data);
175
176 }
177
178 # function to get tag attributes
179 function get_tag_attributes($tag){
180
181 # Extract existing attributes of the <body> tag
182 $attributes = [];
183
184
185 # set the tag attributes
186 $tag_attributes = [];
187
188
189 # check that the tag attributes
190 if(!is_object($tag) || !method_exists($tag, 'getAllAttributes')){
191 return '';
192 }
193
194 # get the tag attributes
195 $tag_attributes = $tag->getAllAttributes();
196
197 # loop all attributes
198 foreach ($tag_attributes as $key => $value) {
199
200 if ($value == 1) {
201
202 # Handle boolean attributes
203 $attributes[] = htmlspecialchars($key, ENT_QUOTES);
204 } else {
205
206 # Handle attributes with values
207 $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
208 }
209 }
210
211 # Convert attributes array to a string
212 $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : '';
213
214 # return the attributes string
215 return $attributes_string;
216 }
217
218 function handle_route_html($route, $replacement_data){
219
220 # Detect if current page uses Brizy and disable SG Cache if so
221 # Using global function defined in otto_pixel.php
222 if (function_exists('metasync_otto_disable_sg_cache_for_brizy')) {
223 metasync_otto_disable_sg_cache_for_brizy();
224 }
225
226 # lablel the Otto Route
227 # label otto requests to avoid loops
228 // $request_body = add_query_arg(
229 // [
230 // 'is_otto_page_fetch' => 1
231 // ],
232 // $route
233 // );
234 # Add blocking flags as URL parameters (no database writes!)
235 $url_params = ['is_otto_page_fetch' => 1];
236
237 # Add blocking flags if available
238 if (!empty($replacement_data['_otto_blocking'])) {
239 $url_params['otto_block_title'] = $replacement_data['_otto_blocking']['block_title'] ? '1' : '0';
240 # For HTTP fetch path, check if any description tags need blocking
241 $block_description_tags = $replacement_data['_otto_blocking']['block_description_tags'] ?? [];
242 $url_params['otto_block_desc'] = !empty($block_description_tags) ? '1' : '0';
243 }
244
245 $request_body = add_query_arg($url_params, $route);
246
247 # TUNNEL/PROXY SUPPORT: If site is behind a tunnel (ngrok, zrok, etc.)
248 # and loopback requests fail, try using localhost instead
249 $request_body = apply_filters('metasync_otto_internal_fetch_url', $request_body, $route);
250 # set cookie header var
251 $cookie_header = '';
252
253 # loop cookies to set header
254 foreach ($_COOKIE as $name => $value) {
255
256 # handle array values by converting to string
257 $cookie_value = is_array($value) ? serialize($value) : $value;
258
259 # add cookie to header
260 # $cookie_header .= $name . '=' . $value . '; ';
261 $cookie_header .= $name . '=' . $cookie_value . '; ';
262 }
263
264 # trim the string
265 $cookie_header = rtrim($cookie_header, '; ');
266
267 # Allow timeout customization for slow tunnel environments
268 $fetch_timeout = apply_filters('metasync_otto_internal_fetch_timeout', 5);
269
270 $args = array(
271 'sslverify' => false, // Disabled for localhost/tunnel environments
272 'timeout' => $fetch_timeout, // Configurable timeout for tunnels
273 'redirection' => 5,
274 'httpversion' => '1.1',
275 'headers' => array(
276 'Cookie' => $cookie_header,
277 'Cache-Control' => 'no-cache, no-store, must-revalidate',
278 'Pragma' => 'no-cache',
279 'X-OTTO-Internal-Fetch' => '1',
280 'User-Agent' => 'MetaSync-OTTO-SSR/3.0',
281 'X-Forwarded-Host' => $_SERVER['HTTP_HOST'] ?? '', // Preserve original host for tunnels
282 )
283 );
284
285 # get the associateed route html
286 $route_html = wp_remote_get($request_body, $args);
287
288 # Check for timeout or connection errors
289 if (is_wp_error($route_html)) {
290 $error_msg = $route_html->get_error_message();
291 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - wp_remote_get error: ' . $error_msg . ' for route: ' . $route);
292 return false;
293 }
294
295 # get body
296 $html_body = wp_remote_retrieve_body($route_html);
297
298 # Get the response code
299 $response_code = wp_remote_retrieve_response_code($route_html);
300
301
302 # check not empty
303 if(empty($html_body) || $response_code !== 200){
304 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - Empty body or non-200 status for route: ' . $route);
305 return false;
306 }
307
308 # Remove XML declaration
309 $html_body = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html_body);
310
311 # now that the html is not empty
312 # load it into the simple html dom
313 $this->dom->load($html_body, true, false);
314
315 # Force UTF-8 charset to preserve emojis and special characters
316 # This overrides any charset detection from HTML meta tags
317 $this->dom->_charset = 'UTF-8';
318 $this->dom->_target_charset = 'UTF-8';
319
320 # PERFORMANCE OPTIMIZATION: Pre-cache commonly accessed DOM elements
321 # This eliminates 8-10 full DOM traversals per page
322 $this->cache_elements();
323
324 # PERFORMANCE OPTIMIZATION: Enable deferred reload to skip intermediate reloads
325 # This reduces 6-10 DOM serialize/deserialize cycles to just 1 final reload
326 $this->deferred_reload = true;
327
328 # now lets do the magic
329
330 # COMPATIBILITY FIX: Transform 'value' to 'recommended_value' if needed
331 # Some API versions return 'value' instead of 'recommended_value'
332 if (!empty($replacement_data['header_replacements'])) {
333 foreach ($replacement_data['header_replacements'] as &$item) {
334 if (isset($item['value']) && !isset($item['recommended_value'])) {
335 $item['recommended_value'] = $item['value'];
336 }
337 }
338 unset($item); // Break reference
339 }
340
341 # start the header html insertion
342 $this->insert_header_html($replacement_data);
343
344 # now we do the header replacements
345 $this->do_header_replacements($replacement_data);
346
347 # now do the body replacements
348 $this->do_body_replacements($replacement_data);
349
350 # now do the footer insertions
351 $this->do_footer_html_insertion($replacement_data);
352
353 # final cleanup: ensure metasync_optimized attribute is removed from AMP pages
354 $this->cleanup_amp_metasync_attribute();
355
356 # CRITICAL FIX: SimpleHtmlDom save() doesn't persist outertext/innertext changes
357 # Use the same manual string replacement approach as process_html_directly
358 $this->deferred_reload = false;
359
360 # Get the HTML as string
361 $result_html = $this->dom->save();
362
363 # Apply manual replacements (same logic as process_html_directly)
364
365 # Apply header replacements manually
366 if (!empty($replacement_data['header_replacements'])) {
367 foreach ($replacement_data['header_replacements'] as $item) {
368 $type = $item['type'] ?? '';
369 $value = $item['recommended_value'] ?? $item['value'] ?? '';
370
371 if (empty($value)) continue;
372
373 if ($type === 'title') {
374 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
375 $result_html = preg_replace('/<title[^>]*>.*?<\/title>/is', '<title>' . $new_value . '</title>', $result_html, 1);
376 } elseif ($type === 'meta') {
377 $name = $item['name'] ?? '';
378 $property = $item['property'] ?? '';
379 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
380
381 if (!empty($name)) {
382 $pattern = '/<meta\s+(?:name=["\']' . preg_quote($name, '/') . '["\']\s+content=["\'][^"\']*["\']|content=["\'][^"\']*["\']\s+name=["\']' . preg_quote($name, '/') . '["\'])\s*\/?>/i';
383 $replacement = '<meta name="' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
384 $count = 0;
385 $result_html = preg_replace($pattern, $replacement, $result_html, -1, $count);
386
387 if ($count === 0) {
388 $result_html = preg_replace('/(<head[^>]*>)/i', '$1' . "\n" . $replacement, $result_html, 1);
389 }
390 } elseif (!empty($property)) {
391 $pattern = '/<meta\s+property=["\']' . preg_quote($property, '/') . '["\']\s+content=["\'][^"\']*["\']\s*\/?>/i';
392 $replacement = '<meta property="' . htmlspecialchars($property, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
393 $count = 0;
394 $result_html = preg_replace($pattern, $replacement, $result_html, -1, $count);
395
396 if ($count === 0) {
397 $result_html = preg_replace('/(<head[^>]*>)/i', '$1' . "\n" . $replacement, $result_html, 1);
398 }
399 }
400 } elseif ($type === 'h1' || $type === 'heading') {
401 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
402 $result_html = preg_replace('/<h1[^>]*>.*?<\/h1>/is', '<h1>' . $new_value . '</h1>', $result_html, 1);
403 }
404 }
405 }
406
407 # Apply insertions — only if DOM insertion didn't already apply it
408 if (!empty($replacement_data['header_html_insertion'])) {
409 $header_html_check = trim($replacement_data['header_html_insertion']);
410 if (strpos($result_html, $header_html_check) === false) {
411 $safe_header = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['header_html_insertion']);
412 $result_html = preg_replace('/(<\/head>)/i', $safe_header . "\n" . '$1', $result_html, 1);
413 }
414 }
415 if (!empty($replacement_data['body_top_html_insertion'])) {
416 $safe_body_top = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_top_html_insertion']);
417 $result_html = preg_replace('/(<body[^>]*>)/i', '$1' . "\n" . $safe_body_top, $result_html, 1);
418 }
419 if (!empty($replacement_data['body_bottom_html_insertion'])) {
420 $safe_body_bottom = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_bottom_html_insertion']);
421 $result_html = preg_replace('/(<\/body>)/i', $safe_body_bottom . "\n" . '$1', $result_html, 1);
422 }
423 if (!empty($replacement_data['footer_html_insertion'])) {
424 $safe_footer = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['footer_html_insertion']);
425 $result_html = preg_replace('/(<\/html>)/i', $safe_footer . "\n" . '$1', $result_html, 1);
426 }
427
428 return $result_html;
429 }
430
431 # do the footer html insertion
432 function do_footer_html_insertion($replacement_data){
433
434 # check that we have footer html
435 if(empty($replacement_data['footer_html_insertion'])){
436 return;
437 }
438
439 # OPTIMIZED: Use cached element instead of DOM traversal
440 $footer = $this->get_cached_element('footer');
441
442 # check that footer is object
443 if(!is_object($footer) || !isset($footer->innertext, $footer->outertext)){
444 return;
445 }
446
447 # get the tag attributes
448 $attributes_string = $this->get_tag_attributes($footer);
449
450 # now do the actual html replacements
451 $footer->outertext = '<footer' . $attributes_string . '>' . $footer->innertext . $replacement_data['footer_html_insertion'].'</footer>';
452
453 # save the document
454 $this->save_reload();
455 }
456
457 # do body replacements
458 function do_body_replacements($replacement_data){
459
460 # start body top html replacements
461 $this->do_body_top_html($replacement_data);
462
463 # start the body bottom html replacements
464 $this->do_body_bottom_html($replacement_data);
465
466 # do the body substitutions
467 $this->do_body_substitutions($replacement_data);
468
469 # Check if the feature is enabled in general settings (Post/Page Editor Settings)
470 $general_settings = get_option('metasync_options')['general'] ?? [];
471 if (!empty($general_settings['open_external_links']) && $general_settings['open_external_links'] == '1') {
472 $this->add_target_blank_to_external_links();
473 }
474
475 # Check if the feature is enabled in seo_controls settings (Indexation Control)
476 $seo_controls = get_option('metasync_options')['seo_controls'] ?? [];
477 if (!empty($seo_controls['add_nofollow_to_external_links']) && $seo_controls['add_nofollow_to_external_links'] === 'true') {
478 $this->add_nofollow_to_external_links();
479 }
480 }
481
482 # body substitutions data
483 function do_body_substitutions($replacement_data){
484
485 # check that we have an array of substitutions
486 if(empty($replacement_data['body_substitutions']) || !is_array($replacement_data['body_substitutions'])){
487 return;
488 }
489
490 # now work on different substitution keys
491 foreach ($replacement_data['body_substitutions'] as $key => $value) {
492
493 # check key categories
494 if($key == 'images'){
495 # do image replacements
496 $this->handle_images($value);
497 }
498 elseif($key == 'headings'){
499 # do heading repalcements
500 $this->do_heading_body_substitutions($value);
501 }
502 elseif($key == 'links'){
503 # do link replacements
504 $this->do_link_body_substitutions($value);
505 }
506
507 }
508
509 # save the document
510 $this->save_reload();
511
512 }
513
514 /**
515 * START BODY SUBSTITUTION FUNCTIONS
516 * @see do_body_substitutions();
517 */
518
519 # image substitions
520 function handle_images($image_data){
521
522 if (empty($image_data) || !is_array($image_data)) {
523 return;
524 }
525
526 # OPTIMIZED: Use cached images instead of DOM traversal
527 $images = $this->get_cached_element('imgs', []);
528
529 if (empty($images)) {
530 return;
531 }
532
533 # PERFORMANCE OPTIMIZATION: O(n²) reduced to O(n)
534 # Single pass with hash map lookup instead of nested loop
535 foreach($images AS $key => $image){
536 # Get image src
537 $image_src = $image->src;
538
539 if (empty($image_src)) {
540 continue;
541 }
542
543 # Hash map lookup O(1) instead of loop O(n)
544 if (isset($image_data[$image_src])) {
545 # Set alt text - Note: This may not persist in all cases
546 # Manual string replacement in process_html_directly() ensures it's applied
547 $new_alt = htmlspecialchars($image_data[$image_src], ENT_QUOTES, 'UTF-8');
548
549 # Get current img tag HTML and update alt attribute
550 $current_html = $image->outertext;
551
552 # Remove existing alt attribute (if any)
553 $updated_html = preg_replace('/\s+alt=(["\'])[^"\']*\1/', '', $current_html);
554
555 # Insert new alt attribute after the opening <img
556 $updated_html = preg_replace('/^<img\s/', '<img alt="' . $new_alt . '" ', $updated_html);
557
558 # Update the element
559 $image->outertext = $updated_html;
560
561 $multi_view_attr = $image->getAttribute('data-et-multi-view');
562 if (!empty($multi_view_attr)) {
563 $this->update_divi_multi_view_alt($image, $image_data[$image_src]);
564 }
565 }
566 }
567 }
568
569 /**
570 * Update Divi's multi-view data attribute with alt text
571 * Divi stores image attributes in a JSON structure within data-et-multi-view
572 *
573 * @param object $image The image DOM element
574 * @param string $alt_text The alt text to set
575 */
576 private function update_divi_multi_view_alt($image, $alt_text) {
577 $multi_view_attr = $image->getAttribute('data-et-multi-view');
578
579 if (!empty($multi_view_attr)) {
580 try {
581 # Decode the JSON
582 $multi_view_data = json_decode($multi_view_attr, true);
583
584 if ($multi_view_data && isset($multi_view_data['schema']['attrs'])) {
585 # Update alt in desktop view
586 if (isset($multi_view_data['schema']['attrs']['desktop'])) {
587 $multi_view_data['schema']['attrs']['desktop']['alt'] = $alt_text;
588 }
589
590 # Update alt in other views if they exist (phone, tablet, etc.)
591 foreach ($multi_view_data['schema']['attrs'] as $view => $attrs) {
592 if (isset($attrs['alt'])) {
593 $multi_view_data['schema']['attrs'][$view]['alt'] = $alt_text;
594 }
595 }
596
597 # Encode back to JSON and update the attribute
598 $updated_json = json_encode($multi_view_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
599 $image->setAttribute('data-et-multi-view', $updated_json);
600
601 # error_log('MetaSync OTTO DEBUG: Updated Divi multi-view alt text');
602 }
603 } catch (Exception $e) {
604 # If JSON decode fails, just log and continue
605 # error_log('MetaSync OTTO DEBUG: Failed to update Divi multi-view - ' . $e->getMessage());
606 }
607 }
608 }
609
610 # heading substitutions
611 function do_heading_body_substitutions($heading_data){
612
613 # loop all data
614 foreach($heading_data AS $okey => $heading){
615
616 # find all occurences of the heading type
617 $occurences = $this->dom->find($heading['type']);
618
619 # loop all occurences
620 foreach ($occurences as $ikey => $heading_old) {
621
622 # get the header text
623 $text = $heading_old->text();
624
625 # check matching text
626 if(trim($heading['current_value'] ?? '') == trim($text ?? '')){
627
628 # set the text
629 $heading_old->innertext = $heading['recommended_value'];
630
631 }
632 }
633
634 }
635
636 # save and reload the dom
637 $this->save_reload();
638 }
639
640 # link replacements
641 function do_link_body_substitutions($swap_data){
642
643 # find all links in the body
644 $links = $this->dom->find('a');
645
646 # loop all links check if we have them in swap data
647 foreach ($links as $key => $link) {
648
649 # check if link matches
650 if(!empty($swap_data[$link->href])){
651
652 # replace the link
653 $link->href = $swap_data[$link->href];
654 }
655
656 }
657 }
658
659 /**
660 * Add rel="nofollow" attribute to external links
661 * Works for both buffer and HTTP rendering methods
662 * Only processes links that don't already have rel="nofollow"
663 */
664 function add_nofollow_to_external_links(){
665
666 # find all links in the body
667 $links = $this->dom->find('a');
668
669 # get home URL for comparison
670 $home_url = rtrim(home_url(), '/');
671 $home_url_lower = strtolower($home_url);
672
673 # loop through all links
674 foreach ($links as $key => $link) {
675
676 # get the href attribute
677 $href = $link->href ?? '';
678
679 # skip if href is empty
680 if (empty($href)) {
681 continue;
682 }
683
684 # check if link is external
685 if ($this->is_external_link($href, $home_url, $home_url_lower)) {
686 # get existing rel attribute
687 $existing_rel = $link->rel ?? '';
688
689 # check if nofollow already exists
690 if (empty($existing_rel)) {
691 # no rel attribute, add nofollow
692 $link->rel = 'nofollow';
693 } elseif (strpos($existing_rel, 'nofollow') === false) {
694 # rel exists but nofollow is not present, add it
695 $link->rel = trim($existing_rel . ' nofollow');
696 }
697 # if nofollow already exists, do nothing
698 }
699 }
700
701 # Note: No need to call save_reload() here - it's called at the end of process_html_directly()
702 # This avoids redundant DOM save/reload operations and improves performance
703 }
704
705 /**
706 * Check if a URL is external
707 *
708 * @param string $url The URL to check
709 * @param string $home_url The home URL without trailing slash
710 * @param string $home_url_lower Lowercase version of home URL
711 * @return bool True if external, false if internal
712 */
713 private function is_external_link($url, $home_url, $home_url_lower) {
714 # Empty or anchor-only links are internal
715 if (empty($url) || $url === '#' || strpos($url, '#') === 0) {
716 return false;
717 }
718
719 # Relative URLs (starting with /) are internal
720 if (strpos($url, '/') === 0 && strpos($url, '//') !== 0) {
721 return false;
722 }
723
724 # Check if URL starts with home URL (case-insensitive)
725 $url_lower = strtolower($url);
726 if (strpos($url_lower, $home_url_lower) === 0) {
727 return false;
728 }
729
730 # If it's a protocol-relative URL (//example.com), check if it matches our domain
731 if (strpos($url, '//') === 0) {
732 $parsed_home = parse_url($home_url);
733 $parsed_link = parse_url($url);
734
735 if (isset($parsed_home['host']) && isset($parsed_link['host'])) {
736 if (strtolower($parsed_home['host']) === strtolower($parsed_link['host'])) {
737 return false;
738 }
739 }
740 }
741
742 # All other URLs are considered external
743 return true;
744 }
745
746 /**
747 * Add target="_blank" attribute to external links
748 * Works for both buffer and HTTP rendering methods
749 * Only processes links that don't already have a target attribute
750 */
751 function add_target_blank_to_external_links(){
752
753 # find all links in the body
754 $links = $this->dom->find('a');
755
756 # get home URL for comparison
757 $home_url = rtrim(home_url(), '/');
758 $home_url_lower = strtolower($home_url);
759
760 # loop through all links
761 foreach ($links as $key => $link) {
762
763 # get the href attribute
764 $href = $link->href ?? '';
765
766 # skip if href is empty or already has target attribute
767 if (empty($href) || !empty($link->target)) {
768 continue;
769 }
770
771 # check if link is external
772 if ($this->is_external_link($href, $home_url, $home_url_lower)) {
773 # add target="_blank" attribute
774 $link->target = '_blank';
775
776 # add rel="noopener noreferrer" for security
777 $existing_rel = $link->rel ?? '';
778 if (empty($existing_rel)) {
779 $link->rel = 'noopener noreferrer';
780 } elseif (strpos($existing_rel, 'noopener') === false) {
781 $link->rel = trim($existing_rel . ' noopener noreferrer');
782 }
783 }
784 }
785
786 # Note: No need to call save_reload() here - it's called at the end of process_html_directly()
787 # This avoids redundant DOM save/reload operations and improves performance
788 }
789
790 # body bottom html replacement code
791 function do_body_bottom_html($insert_data){
792
793 # check that data is availbale
794 if(empty($insert_data['body_bottom_html_insertion'])){
795 return;
796 }
797
798 # OPTIMIZED: Use cached body element
799 $body = $this->get_cached_element('body');
800
801 # set the link property if not empty
802 if(empty($body->outertext)){
803 return;
804 }
805
806
807 # get the tag attributes
808 $attributes_string = $this->get_tag_attributes($body);
809
810 # now do the actual html replacements
811 $body->outertext = '<body' . $attributes_string . '>' . $body->innertext . $insert_data['body_bottom_html_insertion'].'</body>';
812
813 # save the document
814 $this->save_reload();
815 }
816
817 # body top html replacement
818 function do_body_top_html($insert_data){
819
820 # check that data is availbale
821 if(empty($insert_data['body_top_html_insertion'])){
822 return;
823 }
824
825 # OPTIMIZED: Use cached body element
826 $body = $this->get_cached_element('body');
827
828 # get the tag attributes
829 $attributes_string = $this->get_tag_attributes($body);
830
831 # now do the actual html replacements
832 $body->outertext = '<body' . $attributes_string . '>'.$insert_data['body_top_html_insertion'].$body->innertext . '</body>';
833 }
834
835 # this function does the header replacements
836 function do_header_replacements($replacement_data){
837
838 # check that we have header replacements
839 if(empty($replacement_data['header_replacements']) || !is_array($replacement_data['header_replacements'])){
840 return;
841 }
842
843 # Check for custom SEO values from MetaSync SEO Sidebar
844 # Custom values take absolute priority over OTTO suggestions
845 $custom_seo_title = '';
846 $custom_seo_description = '';
847
848 if (function_exists('is_singular') && is_singular()) {
849 $post_id = get_the_ID();
850 if ($post_id) {
851 $custom_seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
852 $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
853 }
854 }
855
856 # now lets do the replacement work
857 foreach($replacement_data['header_replacements'] AS $key => $data){
858
859 # skip cases where type is not specified
860 if(empty($data['type'])){
861 continue;
862 }
863
864 # handle title - skip if custom SEO title exists or no value
865 if($data['type'] == 'title'){
866 if (!empty($custom_seo_title)) {
867 # Skip title replacement - custom SEO title takes priority
868 continue;
869 }
870
871 # Skip if OTTO has no title value
872 if (empty(trim($data['recommended_value'] ?? $data['value'] ?? ''))) {
873 continue;
874 }
875
876 # handle the title logic
877 $this->replace_title($data);
878
879 #
880 continue;
881 }
882
883 # handle canonical links
884 if($data['type'] == 'link' && $data['rel'] === 'canonical'){
885
886 # find the cannonical dom element
887 $link = $this->dom->find('link[rel="canonical"]', 0);
888
889 # set the link property if not empty
890 if(!empty($link->href)){
891 $link->href = $data['recommended_value'] ?? $link->href;
892 }
893
894 #
895 continue;
896 }
897
898 # work on other elemenets not titlte
899 $this->handle_meta_element($data);
900 }
901 }
902
903 # function to handle meta elements other than title
904 function handle_meta_element($data){
905
906 # Skip if OTTO has no value to set - prevents overwriting existing tags (e.g. Yoast)
907 # with empty content when OTTO has no recommendation for this meta field
908 $recommended_value = $data['recommended_value'] ?? $data['value'] ?? '';
909 if (empty(trim($recommended_value))) {
910 return;
911 }
912
913 # Check if this is a description meta tag and if custom description exists
914 # Custom values take absolute priority over OTTO suggestions
915 $name = $data['name'] ?? false;
916 $property = $data['property'] ?? false;
917
918 # Check for custom SEO description
919 if (!empty($name) && $name === 'description') {
920 if (function_exists('is_singular') && is_singular()) {
921 $post_id = get_the_ID();
922 if ($post_id) {
923 $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
924 if (!empty($custom_seo_description)) {
925 # Custom description exists, skip OTTO's suggestion
926 return;
927 }
928 }
929 }
930 }
931
932 # extract property value
933 $property = $data['property'] ?? false;
934
935 # extract name value
936 $name = $data['name'] ?? false;
937
938 # set the selector
939 $meta_selector = '';
940
941 # extend selector
942 if(!empty($name)){
943 $meta_selector .= 'meta[name="' . trim($name) . '"]';
944 }
945
946 # extent if property is defined
947 if(!empty($property)){
948 if (!empty($meta_selector)) {
949 $meta_selector .= ',';
950 }
951 $meta_selector .= 'meta[property="' . trim($property) . '"]';
952 }
953
954 # find the meta gat in the dom
955 $meta_tag = $this->dom->find($meta_selector, 0);
956
957 # if tag not exists add it
958 if(empty($meta_tag)){
959 if($data['type'] == 'meta'){
960
961 # get the attribute
962 $attribute = $property ? 'property' : 'name';
963
964 # call the create metatag function
965 $result = $this->create_metatag($attribute, $data);
966 }
967
968 # return after creation
969 return;
970 }
971
972 # CRITICAL FIX: Clear cache and get fresh reference
973 # Preserve 'imgs' key for later use by handle_images()
974 $preserved_imgs = $this->cached_elements['imgs'] ?? null;
975 $this->cached_elements = [];
976 if ($preserved_imgs !== null) {
977 $this->cached_elements['imgs'] = $preserved_imgs;
978 }
979
980 # Get fresh meta tag reference using same selector
981 $meta_tag_fresh = $this->dom->find($meta_selector, 0);
982
983 if ($meta_tag_fresh) {
984 # Use outertext for replacement
985 $new_value = htmlspecialchars($data['recommended_value'] ?? '', ENT_QUOTES, 'UTF-8');
986
987 # Determine attribute name
988 $attr_name = !empty($data['name']) ? 'name' : 'property';
989 $attr_value = !empty($data['name']) ? $data['name'] : ($data['property'] ?? '');
990
991 # Build new meta tag
992 $meta_tag_fresh->outertext = '<meta ' . $attr_name . '="' . htmlspecialchars($attr_value, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
993 }
994
995
996 }
997
998 # function to handle the page title
999 function replace_title($title_data){
1000
1001 # find the title
1002 $title = $this->dom->find('title', 0) ?? false;
1003
1004 # if none
1005 if($title === false){
1006 return $this->create_title($title_data);
1007 }
1008
1009 # CRITICAL FIX: Clear element cache and get fresh reference
1010 # Preserve 'imgs' key for later use by handle_images()
1011 $preserved_imgs = $this->cached_elements['imgs'] ?? null;
1012 $this->cached_elements = [];
1013 if ($preserved_imgs !== null) {
1014 $this->cached_elements['imgs'] = $preserved_imgs;
1015 }
1016
1017 # Get fresh title element reference
1018 $title_fresh = $this->dom->find('title', 0);
1019
1020 if ($title_fresh) {
1021 # Use outertext for replacement
1022 $new_value = htmlspecialchars($title_data['recommended_value'], ENT_QUOTES, 'UTF-8');
1023 $title_fresh->outertext = '<title>' . $new_value . '</title>';
1024 }
1025
1026 # Don't save_reload here - will happen at the end
1027 # $this->save_reload();
1028 }
1029
1030 # Function to create a title when it's missing
1031 function create_title($title_data) {
1032
1033 # Find the <head> tag
1034 $head = $this->dom->find('head', 0);
1035
1036 # Construct the <title> tag HTML
1037 $title_html = '<title>' . htmlspecialchars($title_data['recommended_value'], ENT_QUOTES) . '</title>';
1038
1039 if (empty($head)) {
1040 return false;
1041 }
1042
1043 # Extract existing attributes of the <head> tag
1044 $attributes = [];
1045
1046 # get the tag attributes
1047 $tag_attributes = $head->getAllAttributes();
1048
1049 # loop all attributes
1050 foreach ($tag_attributes as $key => $value) {
1051
1052 if ($value == 1) {
1053
1054 # Handle boolean attributes
1055 $attributes[] = htmlspecialchars($key, ENT_QUOTES);
1056 } else {
1057
1058 # Handle attributes with values
1059 $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
1060 }
1061 }
1062
1063 # Convert attributes array to a string
1064 $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : '';
1065
1066 # Rebuild the <head> tag, inserting the <title> at the beginning
1067 $head->outertext = '<head' . $attributes_string . '>' . $title_html . $head->innertext . '</head>';
1068
1069 # save and reload DOM
1070 $this->save_reload();
1071 }
1072
1073 # function to create meta tag if none existss
1074 function create_metatag($attribute, $data){
1075
1076 # Find the <head> tag
1077 $head = $this->dom->find('head', 0);
1078
1079 # Construct the <title> tag HTML
1080 $meta_tag = '<meta '.$attribute.' = "'.$data[$attribute].'" content = "'.$data['recommended_value'].'">';
1081
1082 if (empty($head)) {
1083 return false;
1084 }
1085
1086 # Extract existing attributes of the <head> tag
1087 $attributes = [];
1088
1089 # get the tag attributes
1090 $tag_attributes = $head->getAllAttributes();
1091
1092 # loop all attributes
1093 foreach ($tag_attributes as $key => $value) {
1094
1095 if ($value == 1) {
1096
1097 # Handle boolean attributes
1098 $attributes[] = htmlspecialchars($key, ENT_QUOTES);
1099 } else {
1100
1101 # Handle attributes with values
1102 $attributes[] = $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
1103 }
1104 }
1105
1106 # Convert attributes array to a string
1107 $attributes_string = !empty($attributes) ? ' ' . implode(' ', $attributes) : '';
1108
1109 # Rebuild the <head> tag, inserting the <title> at the beginning
1110 $head->outertext = '<head' . $attributes_string . '>' . $meta_tag . $head->innertext . '</head>';
1111
1112 # save and reload DOM
1113 $this->save_reload();
1114 }
1115
1116 # function to detect if current page is an AMP page
1117 function is_amp_page(){
1118
1119 # Check if URL path contains /amp/
1120 $current_url = $_SERVER['REQUEST_URI'] ?? '';
1121 if (strpos($current_url, '/amp/') !== false) {
1122 return true;
1123 }
1124
1125 # Check if URL ends with /amp
1126 if (preg_match('/\/amp\/?$/', $current_url)) {
1127 return true;
1128 }
1129
1130 # Check if amp=1 query parameter is present
1131 if (isset($_GET['amp']) && $_GET['amp'] == '1') {
1132 return true;
1133 }
1134
1135 # Check for other common AMP query parameters
1136 if (isset($_GET['amp']) && !empty($_GET['amp'])) {
1137 return true;
1138 }
1139
1140 return false;
1141 }
1142
1143 # this function insterts header html to the dom
1144 function insert_header_html($data){
1145
1146 # check that we have the header html
1147 if(empty($data['header_html_insertion'])){
1148 #
1149 return;
1150 }
1151
1152 # append the/ html at the start of the header
1153 $head = $this->dom->find('head', 0);
1154
1155 if ($head) {
1156
1157 # Check if this is an AMP page - if so, don't add metasync_optimized attribute
1158 $is_amp_page = $this->is_amp_page();
1159
1160 # Append the new HTML at the start of the <head> tag
1161 # For AMP pages: use clean <head> tag without metasync_optimized attribute
1162 # For non-AMP pages: add metasync_optimized attribute to <head> tag
1163 if ($is_amp_page) {
1164 $head->outertext = '<head>' .$data['header_html_insertion']. $head->innertext . '</head>';
1165 } else {
1166 $head->outertext = '<head metasync_optimized>' .$data['header_html_insertion']. $head->innertext . '</head>';
1167 }
1168
1169 }
1170
1171 # save and reload DOM
1172 $this->save_reload();
1173 }
1174
1175 # function to forcefully remove metasync_optimized attribute from head on AMP pages
1176 function cleanup_amp_metasync_attribute(){
1177
1178 # Only proceed if this is an AMP page
1179 if (!$this->is_amp_page()) {
1180 return;
1181 }
1182
1183 # Find the head tag
1184 $head = $this->dom->find('head', 0);
1185
1186 if (!$head) {
1187 return;
1188 }
1189
1190 # Check if head has metasync_optimized attribute
1191 $head_html = $head->outertext;
1192
1193 # If metasync_optimized attribute is found, remove it
1194 if (strpos($head_html, 'metasync_optimized') !== false) {
1195
1196 # Remove the metasync_optimized attribute from the head tag
1197 # This handles various formats: <head metasync_optimized>, <head metasync_optimized=""> etc.
1198 $cleaned_head_html = preg_replace('/\s*metasync_optimized(?:="[^"]*")?/', '', $head_html);
1199
1200 # Update the head element
1201 $head->outertext = $cleaned_head_html;
1202
1203 }
1204 }
1205
1206 /**
1207 * PERFORMANCE OPTIMIZATION: Pre-cache commonly accessed DOM elements
1208 * Reduces 8-10 full DOM traversals to 1 initial traversal
1209 * Call this once after loading HTML, before processing
1210 */
1211 private function cache_elements() {
1212 if (!$this->dom) {
1213 return;
1214 }
1215
1216 # Cache all commonly accessed elements in one pass
1217 $this->cached_elements = [
1218 'html' => $this->dom->find('html', 0),
1219 'head' => $this->dom->find('head', 0),
1220 'body' => $this->dom->find('body', 0),
1221 'footer' => $this->dom->find('footer', 0),
1222 'title' => $this->dom->find('title', 0),
1223 'imgs' => $this->dom->find('img'),
1224 'links' => $this->dom->find('a'),
1225 'canonical' => $this->dom->find('link[rel="canonical"]', 0),
1226 ];
1227 }
1228
1229 /**
1230 * Get cached element by key, with fallback to DOM find
1231 * @param string $key Element key from cache
1232 * @param mixed $fallback Fallback value if not cached
1233 * @return mixed Cached element or fallback
1234 */
1235 private function get_cached_element($key, $fallback = null) {
1236 return $this->cached_elements[$key] ?? $fallback;
1237 }
1238
1239 # this function saves are reloads the dom for modifications to avoid conflict
1240 function save_reload(){
1241
1242 # PERFORMANCE OPTIMIZATION: Skip reload if deferred
1243 # This reduces multiple serialize/deserialize cycles to just one final reload
1244 if ($this->deferred_reload) {
1245 return;
1246 }
1247
1248 # Cleanup metasync_optimized attribute on AMP pages before saving
1249 $this->cleanup_amp_metasync_attribute();
1250
1251 # DISABLED: Cache file creation temporarily disabled
1252
1253 # if(file_put_contents($this->html_file, $this->dom)){
1254
1255 # load the modified file to the DOM
1256 # $this->dom = new HtmlDocument($this->html_file );
1257 # }
1258
1259 # this code is to be replaced in future
1260 # reson for adding is to prevent caching logged in user pages
1261 # why not just skip saving? it broke the DOM Library
1262 # check user is logged in clear the file
1263
1264 # if(is_user_logged_in()) {
1265 # unlink($this->html_file);
1266 # }
1267
1268 # MEMORY-BASED RELOAD: Instead of file operations, reload DOM from current HTML string
1269 # This prevents DOM breaking while avoiding cache file creation
1270 if($this->dom){
1271 # Get current DOM as HTML string
1272 $current_html = $this->dom->save();
1273
1274 # Reload DOM from the HTML string to refresh internal state
1275 # This replaces the file save/reload cycle that SimpleHtmlDOM expects
1276 $this->dom->load($current_html, true, false);
1277
1278 # Force UTF-8 charset after reload to preserve emojis
1279 $this->dom->_charset = 'UTF-8';
1280 $this->dom->_target_charset = 'UTF-8';
1281 }
1282
1283 }
1284
1285 /**
1286 * Process HTML directly without HTTP request (for buffer approach)
1287 * This is the FAST path - eliminates the internal wp_remote_get call
1288 *
1289 * CRITICAL: This method is called from the output buffer callback.
1290 * If it fails, we must return false so the original HTML can be used.
1291 *
1292 * @param string $html The raw HTML captured from output buffer
1293 * @param array $replacement_data OTTO suggestions/replacement data
1294 * @return HtmlDocument|false Modified DOM or false on failure
1295 * @since 2.6.0
1296 */
1297 function process_html_directly($html, $replacement_data) {
1298 try {
1299 # Validate inputs
1300 if (empty($html) || empty($replacement_data) || !is_array($replacement_data)) {
1301 return false;
1302 }
1303
1304 # Validate HTML is actual HTML content
1305 if (stripos($html, '<html') === false && stripos($html, '<!DOCTYPE') === false) {
1306 return false;
1307 }
1308
1309 # Remove XML declaration if present
1310 $html = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html);
1311
1312 # Load HTML into DOM
1313 $this->dom->load($html, true, false);
1314
1315 # Force UTF-8 charset to preserve emojis and special characters
1316 $this->dom->_charset = 'UTF-8';
1317 $this->dom->_target_charset = 'UTF-8';
1318
1319 # PERFORMANCE OPTIMIZATION: Pre-cache commonly accessed DOM elements
1320 # This eliminates 8-10 full DOM traversals per page
1321 $this->cache_elements();
1322
1323 # PERFORMANCE OPTIMIZATION: Enable deferred reload to skip intermediate reloads
1324 # This reduces 6-10 DOM serialize/deserialize cycles to just 1 final reload
1325 $this->deferred_reload = true;
1326
1327 # Apply blocking flags if available (for SEO plugin coordination)
1328 if (!empty($replacement_data['_otto_blocking'])) {
1329 $block_title = $replacement_data['_otto_blocking']['block_title'] ?? false;
1330 $block_description_tags = $replacement_data['_otto_blocking']['block_description_tags'] ?? [];
1331
1332 # Remove SEO plugin meta tags if OTTO is providing them
1333 if ($block_title || !empty($block_description_tags)) {
1334 $this->remove_conflicting_seo_tags($block_title, $block_description_tags);
1335 }
1336 }
1337
1338 # Apply all OTTO modifications (same as handle_route_html but without HTTP fetch)
1339
1340 # COMPATIBILITY FIX: Transform 'value' to 'recommended_value' if needed
1341 # Some API versions return 'value' instead of 'recommended_value'
1342 if (!empty($replacement_data['header_replacements'])) {
1343 foreach ($replacement_data['header_replacements'] as &$item) {
1344 if (isset($item['value']) && !isset($item['recommended_value'])) {
1345 $item['recommended_value'] = $item['value'];
1346 }
1347 }
1348 unset($item); // Break reference
1349 }
1350
1351 # 1. Header HTML insertion
1352 $this->insert_header_html($replacement_data);
1353
1354 # 2. Header replacements (title, meta, canonical)
1355 $this->do_header_replacements($replacement_data);
1356
1357 # 3. Body replacements (top, bottom, substitutions)
1358 $this->do_body_replacements($replacement_data);
1359
1360 # 4. Footer HTML insertion
1361 $this->do_footer_html_insertion($replacement_data);
1362
1363 # 5. Final cleanup for AMP pages
1364 $this->cleanup_amp_metasync_attribute();
1365
1366 # CRITICAL FIX: Clear cached elements and force DOM to refresh
1367 $this->deferred_reload = false;
1368 $this->cached_elements = []; // Clear our custom cache
1369
1370 # Clear SimpleHtmlDom's internal cache
1371 if (method_exists($this->dom, 'clear')) {
1372 $this->dom->clear();
1373 }
1374
1375
1376 # Try getting HTML via root element instead of save()
1377 $root = $this->dom->root;
1378 if ($root && isset($root->outertext)) {
1379 $result_html = $root->outertext;
1380 } else {
1381 # Fallback to save() method
1382 $result_html = $this->dom->save();
1383 }
1384
1385 # DEBUG: Check if DOM changes persisted
1386 if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $result_html, $matches)) {
1387 }
1388
1389 # Apply header replacements manually via string replacement
1390 if (!empty($replacement_data['header_replacements'])) {
1391
1392 foreach ($replacement_data['header_replacements'] as $idx => $item) {
1393 $type = $item['type'] ?? '';
1394 $value = $item['recommended_value'] ?? $item['value'] ?? '';
1395
1396
1397 if (empty($value)) {
1398 continue;
1399 }
1400
1401 if ($type === 'title') {
1402 # Replace title tag if present; otherwise insert (e.g. when Yoast was blocked and no <title> was output)
1403 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
1404 $title_tag = '<title>' . $new_value . '</title>';
1405 $replaced = preg_replace('/<title[^>]*>.*?<\/title>/is', $title_tag, $result_html, 1);
1406 if ($replaced === $result_html && strpos($result_html, '<title') === false) {
1407 # No <title> in document (common when Yoast is blocked) — insert after <head>
1408 $result_html = preg_replace('/(<head[^>]*>)/i', '$1' . "\n" . $title_tag, $result_html, 1);
1409 } else {
1410 $result_html = $replaced;
1411 }
1412 } elseif ($type === 'meta') {
1413 $name = $item['name'] ?? '';
1414 $property = $item['property'] ?? '';
1415 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
1416
1417 if (!empty($name)) {
1418 # MEMORY OPTIMIZED: Count meta tags without storing all matches
1419 # preg_match_all requires $matches, so we pass it but unset immediately
1420 $before_count = preg_match_all('/<meta[^>]+name=["\']' . preg_quote($name, '/') . '["\'][^>]*>/i', $result_html, $before_matches);
1421
1422 # Free memory immediately after use
1423 unset($before_matches);
1424
1425 # IMPROVED FIX: Remove ALL existing meta tags with this name
1426 # This pattern matches ANY meta tag with name="X" regardless of:
1427 # - Attribute order (name before/after content)
1428 # - Additional attributes (id, class, data-*, etc.)
1429 # - Quote style (single or double quotes)
1430 # - Whitespace variations
1431 # The pattern uses [^>]* to match ANY characters until the closing >
1432 $removed_count = preg_replace_callback(
1433 '/<meta\s+[^>]*name=["\']' . preg_quote($name, '/') . '["\'][^>]*>/i',
1434 function($match) {
1435 return ''; // Remove the tag
1436 },
1437 $result_html,
1438 -1, // Remove all occurrences
1439 $total_removed
1440 );
1441
1442 # Update the HTML with removed tags
1443 if ($total_removed > 0) {
1444 $result_html = $removed_count;
1445 }
1446
1447
1448 # MEMORY OPTIMIZED: Verify removal - count again but free memory immediately
1449 $after_count = preg_match_all('/<meta[^>]+name=["\']' . preg_quote($name, '/') . '["\'][^>]*>/i', $result_html, $after_matches);
1450 unset($after_matches);
1451
1452 # Now insert ONE new meta tag at the TOP of <head>
1453 $replacement = '<meta name="' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '" data-otto="true">';
1454 $result_html = preg_replace('/(<head[^>]*>)/i', '$1' . "\n" . $replacement, $result_html, 1);
1455 } elseif (!empty($property)) {
1456 # Replace meta property tag
1457 $pattern = '/<meta\s+property=["\']' . preg_quote($property, '/') . '["\']\s+content=["\'][^"\']*["\']\s*\/?>/i';
1458 $replacement = '<meta property="' . htmlspecialchars($property, ENT_QUOTES, 'UTF-8') . '" content="' . $new_value . '">';
1459
1460 if (preg_match($pattern, $result_html)) {
1461 $result_html = preg_replace($pattern, $replacement, $result_html, 1);
1462 } else {
1463 # Meta tag doesn't exist, insert it in head
1464 $result_html = preg_replace('/(<head[^>]*>)/i', '$1' . "\n" . $replacement, $result_html, 1);
1465 }
1466 }
1467 } elseif ($type === 'h1' || $type === 'heading') {
1468 # Replace first H1 tag
1469 $new_value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
1470 $pattern = '/<h1[^>]*>.*?<\/h1>/is';
1471
1472 if (preg_match($pattern, $result_html)) {
1473 $result_html = preg_replace($pattern, '<h1>' . $new_value . '</h1>', $result_html, 1);
1474 } else {
1475 }
1476 }
1477 }
1478 }
1479
1480 # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it
1481 if (!empty($replacement_data['header_html_insertion'])) {
1482 $header_html_check = trim($replacement_data['header_html_insertion']);
1483 if (strpos($result_html, $header_html_check) === false) {
1484 $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['header_html_insertion']);
1485 # Insert before </head>
1486 $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1);
1487 }
1488 }
1489
1490 # When Otto block has a title: ensure only ONE <title> (remove non-Otto, then keep first only)
1491 if (!empty($replacement_data['header_html_insertion']) && stripos($replacement_data['header_html_insertion'], '<title') !== false) {
1492 $result_html = preg_replace(
1493 '/<title(?![^>]*data-otto-pixel\s*=\s*["\']dynamic-seo["\'])[^>]*>.*?<\/title>\s*/is',
1494 '',
1495 $result_html
1496 );
1497 $first = true;
1498 $result_html = preg_replace_callback(
1499 '/<title[^>]*>.*?<\/title>\s*/is',
1500 function ($m) use (&$first) {
1501 if ($first) {
1502 $first = false;
1503 return $m[0];
1504 }
1505 return '';
1506 },
1507 $result_html
1508 );
1509 }
1510
1511 # Apply body top HTML insertion
1512 if (!empty($replacement_data['body_top_html_insertion'])) {
1513 $body_top_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_top_html_insertion']);
1514 # Insert after <body>
1515 $result_html = preg_replace('/(<body[^>]*>)/i', '$1' . "\n" . $body_top_html, $result_html, 1);
1516 }
1517
1518 # Apply body bottom HTML insertion
1519 if (!empty($replacement_data['body_bottom_html_insertion'])) {
1520 $body_bottom_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['body_bottom_html_insertion']);
1521 # Insert before </body>
1522 $result_html = preg_replace('/(<\/body>)/i', $body_bottom_html . "\n" . '$1', $result_html, 1);
1523 }
1524
1525 # Apply footer HTML insertion
1526 if (!empty($replacement_data['footer_html_insertion'])) {
1527 $footer_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['footer_html_insertion']);
1528 # Insert before </html>
1529 $result_html = preg_replace('/(<\/html>)/i', $footer_html . "\n" . '$1', $result_html, 1);
1530 }
1531
1532 # CRITICAL FIX: Apply image alt text manually via string replacement
1533 # DOM changes don't persist, must use string replacement
1534 if (!empty($replacement_data['body_substitutions']['images']) && is_array($replacement_data['body_substitutions']['images'])) {
1535 foreach ($replacement_data['body_substitutions']['images'] as $image_url => $alt_text) {
1536 if (empty($alt_text) || strpos($result_html, $image_url) === false) {
1537 continue;
1538 }
1539
1540 $escaped_alt = htmlspecialchars($alt_text, ENT_QUOTES, 'UTF-8');
1541 $escaped_url = preg_quote($image_url, '/');
1542 $img_pattern = '/<img[^>]*src=["\']' . $escaped_url . '["\'][^>]*>/i';
1543
1544 if (preg_match_all($img_pattern, $result_html, $img_matches)) {
1545 foreach ($img_matches[0] as $original_img) {
1546 # Remove ALL existing alt attributes
1547 $new_img = preg_replace('/\s+alt\s*=\s*(["\'])[^"\']*\1/i', '', $original_img);
1548 $new_img = preg_replace('/<img\s+alt\s*=\s*(["\'])[^"\']*\1\s*/i', '<img ', $new_img);
1549
1550 # Add single alt attribute after <img
1551 $new_img = preg_replace('/^<img\s*/i', '<img alt="' . $escaped_alt . '" ', $new_img);
1552
1553 # Update data-et-multi-view JSON if present (Divi theme)
1554 if (strpos($new_img, 'data-et-multi-view') !== false) {
1555 $new_img = preg_replace_callback(
1556 '/data-et-multi-view="([^"]+)"/i',
1557 function($mv_matches) use ($alt_text) {
1558 $json_str = html_entity_decode($mv_matches[1], ENT_QUOTES, 'UTF-8');
1559 $json_data = json_decode($json_str, true);
1560
1561 if ($json_data && isset($json_data['schema']['attrs'])) {
1562 foreach ($json_data['schema']['attrs'] as &$attrs) {
1563 if (array_key_exists('alt', $attrs)) {
1564 $attrs['alt'] = $alt_text;
1565 }
1566 }
1567 unset($attrs);
1568 $new_json = json_encode($json_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
1569 return 'data-et-multi-view="' . str_replace('"', '&quot;', $new_json) . '"';
1570 }
1571 return $mv_matches[0];
1572 },
1573 $new_img
1574 );
1575 }
1576
1577 $result_html = str_replace($original_img, $new_img, $result_html);
1578 }
1579 }
1580 }
1581 }
1582
1583 # DEBUG: Check what we're returning
1584 if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $result_html, $matches)) {
1585 }
1586
1587 # FINAL VERIFICATION: Count meta descriptions in returned HTML
1588 $final_meta_count = preg_match_all('/<meta[^>]+name=["\']description["\'][^>]*>/i', $result_html, $final_meta_matches);
1589 if ($final_meta_count > 0) {
1590 foreach ($final_meta_matches[0] as $idx => $meta) {
1591 }
1592 }
1593
1594 if ($final_meta_count > 1) {
1595 }
1596
1597 # AGGRESSIVE DUPLICATE REMOVAL: Remove any meta description without data-otto marker
1598 # Only runs when OTTO has actually inserted its own description (data-otto marker present)
1599 # This prevents stripping Yoast/plugin descriptions when OTTO has no description to replace
1600
1601 $removal_count = 0;
1602 $otto_has_description = (bool) preg_match('/<meta[^>]*name=["\']description["\'][^>]*data-otto[^>]*>/i', $result_html);
1603 if ($otto_has_description) {
1604 $result_html = preg_replace_callback(
1605 '/<meta\s+([^>]*name=["\']description["\'][^>]*)>/i',
1606 function($match) use (&$removal_count) {
1607 # Keep only if it has data-otto="true"
1608 if (stripos($match[1], 'data-otto') !== false) {
1609 return $match[0]; // Keep OTTO's meta tag
1610 }
1611 # Remove any other meta description
1612 $removal_count++;
1613 return '';
1614 },
1615 $result_html
1616 );
1617 }
1618
1619
1620 # MEMORY OPTIMIZED: Free all large objects and arrays before returning
1621 # This ensures memory is released immediately, especially important for high-traffic sites
1622 unset($final_meta_matches, $matches);
1623
1624 # Clear SimpleHtmlDom internal cache to free memory
1625 # Note: We don't unset $this->dom as the object may be reused
1626 if ($this->dom && method_exists($this->dom, 'clear')) {
1627 $this->dom->clear();
1628 }
1629
1630 # Clear element cache array
1631 $this->cached_elements = [];
1632
1633 return $result_html;
1634
1635 } catch (Exception $e) {
1636 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Exception in process_html_directly - ' . $e->getMessage());
1637 return false;
1638 } catch (Error $e) {
1639 error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Error in process_html_directly - ' . $e->getMessage());
1640 return false;
1641 }
1642 }
1643
1644 /**
1645 * Remove conflicting SEO plugin meta tags from DOM
1646 * Called when OTTO is providing its own title/description
1647 *
1648 * @param bool $remove_title Remove title-related tags
1649 * @param array|bool $remove_description_tags Array of specific description tag selectors to remove, or false/empty array for none
1650 * @since 2.6.0
1651 */
1652 private function remove_conflicting_seo_tags($remove_title = false, $remove_description_tags = []) {
1653 if (!$this->dom) {
1654 return;
1655 }
1656
1657 # Find head element
1658 $head = $this->dom->find('head', 0);
1659 if (!$head) {
1660 return;
1661 }
1662
1663 # Check for custom SEO values from MetaSync SEO Sidebar
1664 # Custom values take absolute priority over OTTO suggestions
1665 $has_custom_title = false;
1666 $has_custom_description = false;
1667
1668 if (function_exists('is_singular') && is_singular()) {
1669 $post_id = get_the_ID();
1670 if ($post_id) {
1671 $custom_seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
1672 $custom_seo_description = get_post_meta($post_id, '_metasync_seo_desc', true);
1673 $has_custom_title = !empty($custom_seo_title);
1674 $has_custom_description = !empty($custom_seo_description);
1675 }
1676 }
1677
1678 # Handle description tags - only remove specific tags that Otto is providing
1679 if (!empty($remove_description_tags) && is_array($remove_description_tags) && !$has_custom_description) {
1680 # Remove only the specific description tags that Otto is providing
1681 foreach ($remove_description_tags as $selector) {
1682 $tags = $this->dom->find($selector);
1683 foreach ($tags as $tag) {
1684 # Remove the tag
1685 $tag->outertext = '';
1686 }
1687 }
1688 }
1689
1690 if ($remove_title && !$has_custom_title) {
1691 # Remove Open Graph and Twitter title tags (keep main <title>)
1692 $title_selectors = [
1693 'meta[property=og:title]',
1694 'meta[name=twitter:title]',
1695 ];
1696
1697 foreach ($title_selectors as $selector) {
1698 $tags = $this->dom->find($selector);
1699 foreach ($tags as $tag) {
1700 $tag->outertext = ''; # Remove the tag
1701 }
1702 }
1703 }
1704
1705 # Save changes
1706 $this->save_reload();
1707 }
1708
1709
1710 }