PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / public / class-metasync-hidden-post.php

class-metasync-hidden-post.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.1, at public/class-metasync-hidden-post.php

484 lines 17.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 class MetaSyncHiddenPostManager
5 {
6 # Define static properties for the hidden post
7 private $post_title; # Title of the hidden post
8 private $post_content; # Default content
9 public $image_url; # Featured image URL
10 private $hidden_post_type; # Non-existent post type to hide it
11
12 # Initialize hooks on WordPress init
13 public function init()
14 {
15 # Get the last execution timestamp
16 $metasync_data = Metasync::get_option('general');
17
18 # Check if 24 hours have passed
19 if (empty($metasync_data['last_run_time']) || (time() - $metasync_data['last_run_time']) > 604800) {
20
21 $this->create_hidden_post(); #create/show post for crawling
22 }
23 }
24
25 public function __construct()
26 {
27 $this->post_title = 'Please do not delete this Metasync test Post'; # Initialize post_title
28 $this->post_content = 'This post, is used by the ' . Metasync::get_effective_plugin_name() . ' plugin, please do not delete.'; # Initialize content
29 $this->hidden_post_type = 'metasync_post_type'; # Initialize Non-existent post type to hide it later
30
31 }
32
33 /**
34 * Upload a local image to WordPress media library and return the URL
35 */
36 private function upload_local_image($file_path)
37 {
38 if (!file_exists($file_path)) {
39 return ''; # Return empty if file doesn't exist
40 }
41
42 require_once ABSPATH . 'wp-admin/includes/file.php';
43 require_once ABSPATH . 'wp-admin/includes/media.php';
44 require_once ABSPATH . 'wp-admin/includes/image.php';
45
46 # Create a temporary copy to prevent deletion
47 $tmp_file_path = wp_tempnam($file_path);
48 copy($file_path, $tmp_file_path);
49
50 # Upload the copied file
51 $file_array = [
52 'name' => basename($file_path),
53 'tmp_name' => $tmp_file_path, # Use the copied file
54 ];
55
56 $attachment_id = media_handle_sideload($file_array, 0);
57
58 # Clean up the temp file
59 if (file_exists($tmp_file_path)) {
60 @wp_delete_file($tmp_file_path); # Delete only the temp copy, not the original file
61 }
62
63 if (is_wp_error($attachment_id)) {
64 return ''; # Return empty if upload fails
65 }
66
67 # Return the URL of the uploaded image
68 return wp_get_attachment_url($attachment_id);
69 }
70
71
72 /**
73 * Get Image url after load
74 */
75
76 public function get_image_url_Data()
77 {
78
79 # Get the latest uploaded image from the media library
80 $latest_image = get_posts([
81 'post_type' => 'attachment', # Fetch only media attachments
82 'post_mime_type' => 'image', # Ensure it's an image
83 'posts_per_page' => 1, # Limit to 1 result
84 'orderby' => 'date', # Get the latest one
85 'order' => 'DESC', # Sort in descending order
86 ]);
87
88
89 # Get local image path
90 $local_image_path = plugin_dir_path(dirname(__FILE__)) . 'assets/banner-1544x500.png';
91
92 # Check if an image exists and retrieve its URL
93 $this->image_url = (!empty($latest_image)) ? wp_get_attachment_url($latest_image[0]->ID) : $this->upload_local_image($local_image_path); # Use default if no image found
94
95
96 }
97
98 /**
99 * Create a hidden post programmatically if it doesn't exist
100 */
101
102 public function create_hidden_post()
103 {
104
105 # Check if the post already exists by title
106 global $wpdb;
107
108 # Get the Data (prepare query inline)
109 $existing_post = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- maintenance of the plugin hidden OG post — direct status/content surgery keeps it invisible to WP_Query consumers
110 "SELECT ID FROM {$wpdb->posts} WHERE post_title = %s AND (post_type = 'post' OR post_type = 'metasync_post_type') LIMIT 1",
111 $this->post_title
112 ));
113
114 $this->get_image_url_Data();
115 if ($existing_post) {
116
117 #show post
118 $this->show_post_completely($existing_post);
119
120 # Update Post meta for Divi
121 $existing_meta = get_post_meta($existing_post, '_et_pb_built_for_post_type', true);
122
123 if ($existing_meta !== '') {
124 # If meta key exists, update it
125 update_post_meta($existing_post, '_et_pb_built_for_post_type', 'post');
126 } else {
127 # If meta key does not exist, add it
128 add_post_meta($existing_post, '_et_pb_built_for_post_type', 'post', true);
129 }
130 # Check if title and image exist in post content and fix if needed
131 $this->check_and_fix_post_content($existing_post);
132
133 $this->check_and_fix_page_content($existing_post);
134
135 # Hide the post completely by changing its post type
136 $this->hide_post_completely($existing_post);
137 return $existing_post;
138 }
139
140 $prepare_content['post_content'] = $this->post_content;
141
142 # Pass the Parameter
143 if (defined('METASYNC_VERSION')) {
144 $version = METASYNC_VERSION;
145 } else {
146 $version = '1.0.0';
147 }
148 $plugin_name = 'metasync';
149
150 # Call the MetaSync REST API Class
151 $content = new Metasync_Rest_Api($plugin_name,$version);
152 $builderData = $content->metasync_upload_post_content($prepare_content);
153
154 # Insert as draft so wp_insert_post does not fire transition_post_status
155 # with 'publish'. Jetpack Social, Zapier, RSS, and similar auto-share
156 # plugins listen to that hook and would broadcast this internal test post.
157 $post_id = wp_insert_post([
158 'post_title' => $this->post_title,
159 'post_content' => $builderData['content'] ? $builderData['content'] : $prepare_content,
160 'post_status' => 'draft',
161 'post_type' => 'post',
162 ]);
163
164 if (is_wp_error($post_id)) {
165 return false;
166 }
167
168 # Flip to publish via direct DB write — bypasses transition_post_status
169 global $wpdb;
170 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- maintenance of the plugin hidden OG post — direct status/content surgery keeps it invisible to WP_Query consumers
171 $wpdb->posts,
172 ['post_status' => 'publish'],
173 ['ID' => $post_id]
174 );
175 clean_post_cache($post_id);
176
177 $post_meta = array();
178
179 # Update Post meta for Page Builder to work
180 if(isset($builderData['elementor_meta_data'])){
181 $post_meta = array_merge($post_meta,$builderData['elementor_meta_data']);
182 }else if(isset($builderData['divi_meta_data'])){
183 $post_meta = array_merge($post_meta,$builderData['divi_meta_data']);
184 $post_meta['_et_pb_ab_current_shortcode']='[et_pb_split_track id="'.$post_id.'" /]';
185 $post_meta['_et_pb_use_builder']='on'; # Turn on the page Builder
186 $post_meta['_et_pb_built_for_post_type']='post';
187 }
188
189 # Add custom fields to posts and pages
190 foreach ($post_meta as $key => $value) {
191 # Add Post Meta
192 add_post_meta($post_id, $key, $value, true);
193
194 }
195
196 # check if the feature image is empty
197 if (!empty($this->image_url)) {
198
199 # Set a featured image for the post
200 $this->set_featured_image($post_id, $this->image_url);
201 }
202
203
204 if (isset( $builderData['elementor_meta_data']) && did_action( 'elementor/loaded' )) {
205 # Clear Elementor cache for the specified post ID
206 \Elementor\Plugin::instance()->files_manager->clear_cache();
207
208 }
209
210 # Check if title and image exist in post content and fix if needed
211 $this->check_and_fix_post_content($post_id);
212
213 $this->check_and_fix_page_content($post_id);
214
215 # Hide the post completely by changing its post type
216 $this->hide_post_completely($post_id);
217
218 return $post_id;
219 }
220
221
222 /**
223 * Change the post type to page and check the
224 */
225 private function check_and_fix_page_content($post_id)
226 {
227 # Switch to page type via direct DB write — bypasses transition_post_status
228 global $wpdb;
229 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- maintenance of the plugin hidden OG post — direct status/content surgery keeps it invisible to WP_Query consumers
230 $wpdb->posts,
231 [
232 'post_type' => 'page',
233 'post_status' => 'publish',
234 ],
235 ['ID' => $post_id]
236 );
237 clean_post_cache($post_id);
238
239 # Update Post meta for Divi
240 $existing_meta = get_post_meta($post_id, '_et_pb_built_for_post_type', true);
241
242 if ($existing_meta !== '') {
243 # If meta key exists, update it
244 update_post_meta($post_id, '_et_pb_built_for_post_type', 'page');
245 } else {
246 # If meta key does not exist, add it
247 add_post_meta($post_id, '_et_pb_built_for_post_type', 'page', true);
248 }
249
250 # Check the page has the feature image and the Page title
251 $this->check_and_fix_post_content($post_id,'page');
252 }
253
254 /**
255 * Attach a featured image to the post if missing
256 */
257 private function set_featured_image($post_id, $image_url)
258 {
259 require_once ABSPATH . 'wp-admin/includes/image.php';
260 require_once ABSPATH . 'wp-admin/includes/file.php';
261 require_once ABSPATH . 'wp-admin/includes/media.php';
262
263 # Download and attach the image to the post
264 $media_id = media_sideload_image($image_url, $post_id, null, 'id');
265
266 if (!is_wp_error($media_id)) {
267 set_post_thumbnail($post_id, $media_id); # Set as featured image
268 }
269 }
270
271 /**
272 * Check if the post content contains the title and featured image, add them if missing
273 * add post title and post feature image on the content
274 */
275 private function check_and_fix_post_content($post_id,$post_type='post')
276 {
277 if (!class_exists('DOMDocument')) {
278 error_log('MetaSync: skipping check_and_fix_post_content — DOMDocument (php-dom extension) is not available on this server.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
279 return false;
280 }
281
282 # Get the post title
283 $post_title = get_the_title($post_id);
284
285 # Get the Metasync Option
286 $metasyncData = Metasync::get_option();
287 # Prepare HTML content to prepend if title or image is missing
288 $prepend_content = '';
289
290 # Get the featured image URL of the post
291 $featured_image_url = get_the_post_thumbnail_url($post_id, 'full');
292
293 # Check for [.webp,.jpg,.png] pattern
294 if (!empty($featured_image_url)) {
295 $featured_image_url = preg_replace('/\.[^.]+$/', '', $featured_image_url);
296 }
297
298 # Get the preview URL of the post
299 $post_url = get_permalink($post_id);
300
301 $probe_result = $this->fetch_post_html($post_url);
302 $html = $probe_result['body'];
303 $http_code = $probe_result['status'];
304
305 # Only trust the probe when we verifiably fetched the test post.
306 # A cache 404, bot challenge, or security-plugin block used to parse as
307 # "title not found" and flip title_in_headings to false, which made every
308 # synced post get an extra <h1> prepended (duplicate H1 on the live page).
309 # "please do not delete" appears in both the test post title and body.
310 if (empty($html) || $http_code !== 200 || stripos($html, 'please do not delete') === false) {
311
312 # Inconclusive fetch: keep the previously detected values and record
313 # the run so the probe is not retried on every request.
314 $metasyncData['general']['last_run_time'] = time();
315 Metasync::set_option($metasyncData);
316 return false;
317 }
318
319 # Load the HTML content into DOMDocument
320 $dom = new DOMDocument();
321 libxml_use_internal_errors(true); # Suppress HTML parsing errors
322 $dom->loadHTML($html); # Parse the HTML content
323 libxml_clear_errors(); # Clear any errors
324
325 # Use XPath to search for specific elements in the HTML
326 $xpath = new DOMXPath($dom);
327
328 # Query all heading tags (h1 - h6) and img tags
329 $elements = $xpath->query("//h1 | //h2 | //h3 | //h4 | //h5 | //h6 | //img");
330
331 # Flags to check if title or image is present
332 $title_in_headings = false;
333 $image_in_content = false;
334
335 # Arrays to store found headings and images
336 $headings = [];
337 $image_tags = [];
338
339 # Loop through all queried elements
340 foreach ($elements as $element) {
341 if (in_array($element->nodeName, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])) {
342 # Process heading tags
343 $heading_text = trim($element->textContent); # Get the text content of the heading
344 $headings[] = [
345 'tag' => $element->nodeName, # Store the tag type (h1, h2, etc.)
346 'text' => $heading_text # Store the heading text
347 ];
348
349 # Check if the post title is present in any heading
350 if (stripos($heading_text, $post_title) !== false) {
351 $title_in_headings = true;
352 }
353 } elseif ($element->nodeName === 'img') {
354 # Process image tags
355 $img_src = $element->getAttribute("src"); # Get the image source URL
356 $image_tags[] = $img_src; # Store the image URL
357
358 # Check if the featured image is already present in content
359 if (!empty($featured_image_url) && stripos($img_src, $featured_image_url) !== false) {
360 $image_in_content = true;
361 }
362 }
363 }
364
365
366 # If the post title is found title_in_headings to true
367 if ($title_in_headings) {
368
369
370 # Update Metasync data to indicate the title is already there
371 $metasyncData['general']['title_in_headings'][$post_type] = true;
372 } else {
373
374 # Mark that the title was not there.
375 $metasyncData['general']['title_in_headings'][$post_type] = false;
376 }
377
378 # If the featured image is found image_in_content to true or make it true if there is no feature image
379 if ($image_in_content || empty($featured_image_url)) {
380
381 # Update Metasync data to indicate the image is already there
382 $metasyncData['general']['image_in_content'][$post_type] = true;
383 } else {
384
385 # Mark that the image was not there.
386 $metasyncData['general']['image_in_content'][$post_type] = false;
387 }
388
389 # Update the timestamp
390 $metasyncData['general']['last_run_time'] = time();
391
392 # Save the updated Metasync options
393 Metasync::set_option($metasyncData);
394
395 # Return the content that needs to be prepended
396 return [
397 'content' => $prepend_content
398 ];
399 }
400
401 /**
402 * Fetch the hidden test post for content detection.
403 *
404 * @param string $post_url Hidden test post URL.
405 * @return array{body: string|false, status: int}
406 */
407 protected function fetch_post_html($post_url)
408 {
409 $response = wp_remote_get(
410 $post_url,
411 [
412 'timeout' => 15,
413 'redirection' => 5,
414 'sslverify' => true,
415 ]
416 );
417
418 if (is_wp_error($response)) {
419 return [
420 'body' => false,
421 'status' => 0,
422 ];
423 }
424
425 return [
426 'body' => wp_remote_retrieve_body($response),
427 'status' => (int) wp_remote_retrieve_response_code($response),
428 ];
429 }
430
431 /**
432 * Change post type to a post type to hide it from admin
433 */
434 private function show_post_completely($post_id)
435 {
436 global $wpdb;
437
438 # Single direct DB write for both type + status — bypasses
439 # transition_post_status so auto-share plugins never see this post
440 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- maintenance of the plugin hidden OG post — direct status/content surgery keeps it invisible to WP_Query consumers
441 $wpdb->posts,
442 [
443 'post_type' => 'post',
444 'post_status' => 'publish',
445 ],
446 ['ID' => $post_id]
447 );
448 clean_post_cache($post_id);
449 }
450 /**
451 * Change post type to a non-existent type to hide it from admin
452 */
453 private function hide_post_completely($post_id)
454 {
455 global $wpdb;
456
457 # Single direct DB write — bypasses transition_post_status on the
458 # publish→draft transition (some plugins hook that direction too)
459 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- maintenance of the plugin hidden OG post — direct status/content surgery keeps it invisible to WP_Query consumers
460 $wpdb->posts,
461 [
462 'post_status' => 'draft',
463 'post_type' => 'metasync_post_type',
464 ],
465 ['ID' => $post_id]
466 );
467 clean_post_cache($post_id);
468 }
469
470 /**
471 * Prevent deletion of the hidden post
472 */
473 public function prevent_post_deletion($post_id)
474 {
475 global $wpdb;
476 $post = get_post($post_id);
477
478 // Check if post exists before accessing its properties
479 if ($post && $post->post_type === $this->hidden_post_type) {
480 } // wp_die('This post cannot be deleted.'); # Block deletion
481
482 }
483 }
484