final class TAB_Engine { private static $instance; private $settings; // Hard floor, independent of the tunable min_words admin setting: // nothing this plugin creates, pulls, or recovers may reach // post_status=publish below this word count or without a featured // image, regardless of require_images/unattended_mode. Those settings // still control the softer "keep as draft and flag for a human" // behavior above this floor; this constant is the guard that can't be // configured away. const HARD_MIN_WORDS = 700; /** * True only when a post is genuinely ready to go live: real body * length and a real featured image. Every code path that can set * post_status to 'publish' for content this plugin generated, pulled, * or recovered must pass through this -- see HARD_MIN_WORDS' comment. */ private function ready_to_publish($post_id, $words) { return $words >= self::HARD_MIN_WORDS && (bool) get_post_thumbnail_id($post_id); } public static function instance() { return self::$instance ?: (self::$instance = new self()); } private function __construct() { $this->settings = tab_settings(); add_action('tab_rebuild_existing_post', array($this, 'rebuild_existing_post'), 10, 2); add_action('wp_ajax_tab_rebuild_worker', array($this, 'run_native_rebuild_worker')); add_action('wp_ajax_nopriv_tab_rebuild_worker', array($this, 'run_native_rebuild_worker')); add_action('tab_complete_draft_post', array($this, 'complete_draft_post'), 10, 2); } /** Process one native rebuild item per batch on the server cron tick. */ public function run_rebuild_queue_tick() { foreach ((array)get_option('tab_rebuild_batches', array()) as $batch) { $queue = array_values(array_map('absint', (array)get_option('tab_rebuild_queue_'.sanitize_text_field($batch), array()))); if ($queue) $this->process_native_queue_directly($batch); } } /** * create_rebuild_batch() (below) only ever selects post_status='publish' * rows -- rebuild_existing_post() itself hard-requires 'publish' too * (line ~196), so neither one will touch a draft at all. This is a * parallel path for the opposite, real-world case found live on this * install: hundreds of posts stuck in draft status with stub/partial * content (as little as 0-19 words) from interrupted or pre-fallback-key * generation runs. Mirrors create_rebuild_batch's shape exactly, just * selecting drafts under the word-count floor OR missing a featured * image, and only transitions draft -> publish once BOTH the generated * content and a real featured image are verified present -- never * publishes a post that's still thin or imageless, matching the * "publish nothing incomplete" requirement this exists to satisfy. */ public function create_draft_completion_batch($limit = 500, $enqueue = false) { $this->settings = tab_settings(); $limit = max(1, min(2000, (int)$limit)); $target_min = max(300, (int)$this->settings['min_words']); $target_max = max($target_min, (int)$this->settings['max_words']); global $wpdb; $rows = $wpdb->get_results( "SELECT p.ID, p.post_title, p.post_content FROM {$wpdb->posts} p WHERE p.post_type = 'post' AND p.post_status = 'draft' AND NOT EXISTS (SELECT 1 FROM {$wpdb->postmeta} a WHERE a.post_id=p.ID AND a.meta_key='_tab_draft_status' AND a.meta_value IN ('queued','processing','failed')) ORDER BY p.ID ASC" ); $candidates = array(); foreach ($rows as $row) { $id = (int)$row->ID; $words = $this->article_word_count((string)$row->post_content); $has_thumb = (bool) get_post_thumbnail_id($id); if ($words >= $target_min && $has_thumb) continue; // already complete -- leave for the plain publish pass $candidates[] = array( 'id' => $id, 'words' => $words, 'thumb' => $has_thumb, 'title' => $row->post_title, ); } $candidates = array_slice($candidates, 0, $limit); $batch = 'draftfix-'.gmdate('Ymd-His').'-'.strtolower(wp_generate_password(6, false, false)); $manifest = array( 'batch' => $batch, 'requested' => $limit, 'selected' => count($candidates), 'created_at' => current_time('mysql'), 'enqueued' => (bool)$enqueue, 'target_min_words' => $target_min, 'target_max_words' => $target_max, 'posts' => $candidates, ); if ($enqueue) { $native_queue = array(); foreach ($candidates as $candidate) { update_post_meta($candidate['id'], '_tab_draft_batch', $batch); update_post_meta($candidate['id'], '_tab_draft_status', 'queued'); delete_post_meta($candidate['id'], '_tab_draft_error'); if (function_exists('as_enqueue_async_action')) { as_enqueue_async_action('tab_complete_draft_post', array($candidate['id'], $batch), 'tamfis-draftfix-'.$batch, false, 20); } else { $native_queue[] = (int)$candidate['id']; } } if ($native_queue) update_option('tab_draft_queue_'.$batch, $native_queue, false); update_option('tab_draft_batch_'.$batch, $manifest, false); $batches = (array)get_option('tab_draft_batches', array()); array_unshift($batches, $batch); update_option('tab_draft_batches', array_slice(array_values(array_unique($batches)), 0, 25), false); $this->dispatch_rebuild_queue($batch); } return $manifest; } public function complete_draft_post($post_id, $batch) { $post_id = absint($post_id); $post = get_post($post_id); if (!$post || 'post' !== $post->post_type || 'draft' !== $post->post_status) return; if ((string)get_post_meta($post_id, '_tab_draft_batch', true) !== (string)$batch) return; update_post_meta($post_id, '_tab_draft_status', 'processing'); $target_min_words = max(self::HARD_MIN_WORDS, (int)$this->settings['min_words']); $target_max_words = max($target_min_words, (int)$this->settings['max_words']); $source_url = esc_url_raw((string)get_post_meta($post_id, '_tab_source_url', true)); $item = array( 'title' => wp_strip_all_tags($post->post_title), 'url' => $source_url ?: get_permalink($post_id), 'content' => $this->clean_source_text($post->post_content), 'excerpt' => $this->clean_source_text($post->post_excerpt), 'image' => get_the_post_thumbnail_url($post_id, 'full') ?: '', 'source' => $source_url ? (string)wp_parse_url($source_url, PHP_URL_HOST) : '', 'timestamp' => strtotime($post->post_date_gmt.' UTC') ?: time(), ); $article = null; $content = ''; $words = 0; for ($attempt = 1; $attempt <= 3; $attempt++) { $article = 1 === $attempt ? $this->generate_article($item) : $this->normalize_article_length($article, $item, $words); if (is_wp_error($article)) break; $attribution = $this->settings['source_attribution'] ?? 1; if (!$source_url) $this->settings['source_attribution'] = 0; $content = $this->format_article($article, $item); $this->settings['source_attribution'] = $attribution; $words = $this->article_word_count($content); if ($words >= $target_min_words && $words <= $target_max_words) break; } if (is_wp_error($article)) { $this->record_draft_failure($post_id, $article->get_error_message()); return; } if ($words < $target_min_words || $words > $target_max_words) { $this->record_draft_failure($post_id, 'Generated article failed length validation at '.$words.' words.'); return; } $updated = wp_update_post(array( 'ID' => $post_id, 'post_excerpt' => sanitize_textarea_field($article['excerpt'] ?? $post->post_excerpt), 'post_content' => $content, ), true); if (is_wp_error($updated)) { $this->record_draft_failure($post_id, $updated->get_error_message()); return; } // Ensure a real featured image exists, reusing the same stock-photo // fallback the main creation pipeline (create_post()) uses. $attachment_id = get_post_thumbnail_id($post_id); if (!$attachment_id) { $image_url = $item['image'] ?: ''; if (!$image_url) { $query = !empty($article['image_query']) ? $article['image_query'] : $article['title']; $image_url = $this->fetch_stock_photo($query); } if ($image_url) { $attachment_id = $this->attach_image($post_id, $image_url, $article['title']); } } // HARD_MIN_WORDS guard: image requirement is unconditional here, // not gated behind the require_images setting -- see // ready_to_publish()'s comment. $has_image = $attachment_id && !is_wp_error($attachment_id); if (!$has_image) { $this->record_draft_failure($post_id, 'No usable featured image could be found or downloaded.'); return; } if (!empty($this->settings['inline_images']) && strpos($content, 'tab-inline-image') === false) { $content = $this->insert_inline_image(get_post_field('post_content', $post_id, 'raw'), $attachment_id, $article['title']); wp_update_post(array('ID' => $post_id, 'post_content' => $content)); } // Only publish once content AND image are both actually verified // complete against the stored row -- never publish a post that's // still thin or imageless, per the explicit "detect quality first" // requirement this method exists to satisfy. $final_words = $this->article_word_count((string)get_post_field('post_content', $post_id, 'raw')); if (!$this->ready_to_publish($post_id, $final_words)) { $this->record_draft_failure($post_id, 'Stored content only '.$final_words.' words after update, or featured image missing.'); return; } wp_update_post(array('ID' => $post_id, 'post_status' => 'publish')); update_post_meta($post_id, '_tab_draft_status', 'completed'); update_post_meta($post_id, '_tab_ai_generated', 1); $this->log('success', 'Completed and published draft #'.$post_id.': '.$article['title']); } public function get_draft_batch_status($batch) { $batch = sanitize_text_field($batch); $manifest = get_option('tab_draft_batch_'.$batch, array()); global $wpdb; $counts = $wpdb->get_results($wpdb->prepare( "SELECT s.meta_value AS status, COUNT(*) AS c FROM {$wpdb->postmeta} b LEFT JOIN {$wpdb->postmeta} s ON s.post_id=b.post_id AND s.meta_key='_tab_draft_status' WHERE b.meta_key='_tab_draft_batch' AND b.meta_value=%s GROUP BY s.meta_value", $batch ), ARRAY_A); $manifest['status_counts'] = $counts; return $manifest; } private function record_draft_failure($post_id, $message) { update_post_meta($post_id, '_tab_draft_status', 'failed'); update_post_meta($post_id, '_tab_draft_error', sanitize_text_field($message)); $this->log('error', 'Draft completion failed for #'.$post_id.': '.$message); } public function create_rebuild_batch($limit = 500, $enqueue = false, $source_min_words = null) { $this->settings = tab_settings(); $limit = max(1, min(2000, (int)$limit)); $target_min = max(300, (int)$this->settings['min_words']); $target_max = max($target_min, (int)$this->settings['max_words']); $source_min_words = null === $source_min_words ? (int)($this->settings['rebuild_source_min_words'] ?? 300) : (int)$source_min_words; $source_min_words = max(100, min($target_min - 1, $source_min_words)); global $wpdb; $rows = $wpdb->get_results( "SELECT p.ID, p.post_title, p.post_content FROM {$wpdb->posts} p WHERE p.post_type = 'post' AND p.post_status = 'publish' AND NOT EXISTS (SELECT 1 FROM {$wpdb->postmeta} d WHERE d.post_id=p.ID AND d.meta_key='_tamfis_editorial_rebuild' AND d.meta_value<>'') AND NOT EXISTS (SELECT 1 FROM {$wpdb->postmeta} a WHERE a.post_id=p.ID AND a.meta_key='_tab_rebuild_status' AND a.meta_value IN ('queued','processing','failed')) ORDER BY p.ID DESC" ); $candidates = array(); foreach ($rows as $row) { $id = (int)$row->ID; $words = $this->article_word_count((string)$row->post_content); if ($words < $source_min_words || $words >= $target_min) continue; $candidates[] = array( 'id' => $id, 'words' => $words, 'featured' => (bool)get_post_thumbnail_id($id), 'source' => (bool)get_post_meta($id, '_tab_source_url', true), 'title' => $row->post_title, ); } usort($candidates, static function($a, $b) { if ($a['words'] === $b['words']) return $b['id'] <=> $a['id']; return $b['words'] <=> $a['words']; }); $candidates = array_slice($candidates, 0, $limit); $batch = 'legacy-'.gmdate('Ymd-His').'-'.strtolower(wp_generate_password(6, false, false)); $manifest = array( 'batch' => $batch, 'requested' => $limit, 'selected' => count($candidates), 'created_at' => current_time('mysql'), 'enqueued' => (bool)$enqueue, 'source_min_words' => $source_min_words, 'target_min_words' => $target_min, 'target_max_words' => $target_max, 'posts' => $candidates, ); if ($enqueue) { if (count($candidates) === 0) return new WP_Error('tab_no_candidates', 'No eligible posts found for the selected criteria. Please check the source and target word limits and try again.'); $native_queue = array(); foreach ($candidates as $candidate) { update_post_meta($candidate['id'], '_tab_rebuild_batch', $batch); update_post_meta($candidate['id'], '_tab_rebuild_status', 'queued'); update_post_meta($candidate['id'], '_tab_rebuild_source_min_words', $source_min_words); update_post_meta($candidate['id'], '_tab_rebuild_target_min_words', $target_min); update_post_meta($candidate['id'], '_tab_rebuild_target_max_words', $target_max); delete_post_meta($candidate['id'], '_tab_rebuild_error'); if (function_exists('as_enqueue_async_action')) { as_enqueue_async_action('tab_rebuild_existing_post', array($candidate['id'], $batch), 'tamfis-rebuild-'.$batch, false, 20); } else { $native_queue[] = (int)$candidate['id']; } } if ($native_queue) update_option('tab_rebuild_queue_'.$batch, $native_queue, false); update_option('tab_rebuild_batch_'.$batch, $manifest, false); $batches = (array)get_option('tab_rebuild_batches', array()); array_unshift($batches, $batch); update_option('tab_rebuild_batches', array_slice(array_values(array_unique($batches)), 0, 25), false); $this->dispatch_rebuild_queue($batch); } return $manifest; } public function dispatch_rebuild_queue($batch = '') { if (defined('WP_CLI') && WP_CLI) return; if (class_exists('ActionScheduler_QueueRunner')) { ActionScheduler_QueueRunner::instance()->maybe_dispatch_async_request(); return; } if (!$batch) return; $token = (string)get_option('tab_rebuild_worker_token', ''); if (!$token) { $token = wp_generate_password(48, false, false); update_option('tab_rebuild_worker_token', $token, false); } // Try to trigger the worker via HTTP request $response = wp_remote_post(admin_url('admin-ajax.php'), array( 'timeout' => 5, // Increased timeout 'blocking' => false, 'sslverify' => apply_filters('https_local_ssl_verify', false), 'body' => array('action'=>'tab_rebuild_worker','batch'=>$batch,'token'=>$token), )); // If HTTP request fails, fall back to processing the queue directly // to ensure bulk operations still work even if external requests are blocked if (is_wp_error($response)) { // Process one item from the queue directly to keep things moving $this->process_native_queue_directly($batch); } } /** * Process one item from the native rebuild queue directly (fallback when HTTP requests fail) */ private function process_native_queue_directly($batch) { $batch = sanitize_text_field($batch); $lock = 'tab_rebuild_lock_'.md5($batch); // Prevent concurrent processing if (get_transient($lock)) { return; } set_transient($lock, 1, 5 * MINUTE_IN_SECONDS); $queue_key = 'tab_rebuild_queue_'.$batch; $queue = array_values(array_map('absint', (array)get_option($queue_key, array()))); $post_id = array_shift($queue); if (!$post_id) { // Queue is empty, clean up delete_option($queue_key); delete_transient($lock); return; } // Save updated queue update_option($queue_key, $queue, false); try { $this->rebuild_existing_post($post_id, $batch); } finally { delete_transient($lock); } // If there are still items in the queue, schedule another batch if (!empty($queue)) { $this->dispatch_rebuild_queue($batch); } else { // Queue is now empty, clean up the queue option delete_option($queue_key); } } public function run_native_rebuild_worker() { $batch = sanitize_text_field(wp_unslash($_POST['batch'] ?? '')); $token = sanitize_text_field(wp_unslash($_POST['token'] ?? '')); $expected = (string)get_option('tab_rebuild_worker_token', ''); if (!$batch || !$expected || !hash_equals($expected, $token)) wp_send_json_error(array('message'=>'Unauthorized.'), 403); $lock = 'tab_rebuild_lock_'.md5($batch); if (get_transient($lock)) wp_send_json_success(array('status'=>'busy')); set_transient($lock, 1, 5 * MINUTE_IN_SECONDS); $queue_key = 'tab_rebuild_queue_'.$batch; $queue = array_values(array_map('absint', (array)get_option($queue_key, array()))); $post_id = array_shift($queue); if (!$post_id) { delete_option($queue_key); delete_transient($lock); wp_send_json_success(array('status'=>'complete')); } update_option($queue_key, $queue, false); try { $this->rebuild_existing_post($post_id, $batch); } finally { delete_transient($lock); } if ($queue) $this->dispatch_rebuild_queue($batch); else delete_option($queue_key); wp_send_json_success(array('status'=>$queue?'continuing':'complete','remaining'=>count($queue))); } public function get_rebuild_batch_status($batch) { $batch = sanitize_text_field($batch); $manifest = get_option('tab_rebuild_batch_'.$batch, array()); if (!$manifest) return new WP_Error('tab_batch_missing', 'Batch not found.'); global $wpdb; $rows = $wpdb->get_results($wpdb->prepare( "SELECT COALESCE(s.meta_value,'queued') status, COUNT(*) total FROM {$wpdb->postmeta} b LEFT JOIN {$wpdb->postmeta} s ON s.post_id=b.post_id AND s.meta_key='_tab_rebuild_status' WHERE b.meta_key='_tab_rebuild_batch' AND b.meta_value=%s GROUP BY COALESCE(s.meta_value,'queued')", $batch )); $counts = array('queued'=>0,'processing'=>0,'complete'=>0,'failed'=>0,'cancelled'=>0); foreach ($rows as $row) $counts[$row->status] = (int)$row->total; $selected = (int)($manifest['selected'] ?? array_sum($counts)); $finished = $counts['complete'] + $counts['failed'] + $counts['cancelled']; return array_merge($manifest, array( 'counts' => $counts, 'finished' => $finished, 'progress' => $selected ? round(($finished / $selected) * 100, 1) : 0, )); } public function get_rebuild_batches($limit = 10) { $out = array(); foreach (array_slice((array)get_option('tab_rebuild_batches', array()), 0, max(1, (int)$limit)) as $batch) { $status = $this->get_rebuild_batch_status($batch); if (!is_wp_error($status)) $out[] = $status; } return $out; } public function retry_rebuild_failures($batch) { global $wpdb; $ids = $wpdb->get_col($wpdb->prepare( "SELECT b.post_id FROM {$wpdb->postmeta} b INNER JOIN {$wpdb->postmeta} s ON s.post_id=b.post_id WHERE b.meta_key='_tab_rebuild_batch' AND b.meta_value=%s AND s.meta_key='_tab_rebuild_status' AND s.meta_value='failed'", $batch )); foreach ($ids as $id) { update_post_meta($id, '_tab_rebuild_status', 'queued'); delete_post_meta($id, '_tab_rebuild_error'); if (function_exists('as_enqueue_async_action')) { as_enqueue_async_action('tab_rebuild_existing_post', array((int)$id, $batch), 'tamfis-rebuild-'.$batch, false, 20); } else { $queue = (array)get_option('tab_rebuild_queue_'.$batch, array()); $queue[] = (int)$id; update_option('tab_rebuild_queue_'.$batch, array_values(array_unique($queue)), false); } } $this->dispatch_rebuild_queue($batch); return count($ids); } public function rebuild_existing_post($post_id, $batch) { $post_id = absint($post_id); $post = get_post($post_id); if (!$post || 'post' !== $post->post_type || 'publish' !== $post->post_status) return; if ((string)get_post_meta($post_id, '_tab_rebuild_batch', true) !== (string)$batch) return; update_post_meta($post_id, '_tab_rebuild_status', 'processing'); $source_min_words = max(100, (int)(get_post_meta($post_id, '_tab_rebuild_source_min_words', true) ?: 700)); $target_min_words = max(self::HARD_MIN_WORDS, (int)(get_post_meta($post_id, '_tab_rebuild_target_min_words', true) ?: $this->settings['min_words'])); $target_max_words = max($target_min_words, (int)(get_post_meta($post_id, '_tab_rebuild_target_max_words', true) ?: $this->settings['max_words'])); $this->settings['min_words'] = $target_min_words; $this->settings['max_words'] = $target_max_words; $original_words = $this->article_word_count($post->post_content); if ($original_words < $source_min_words || $original_words >= $target_min_words) { $this->record_rebuild_failure($post_id, 'Post no longer meets this batch eligibility range.'); return; } $source_url = esc_url_raw((string)get_post_meta($post_id, '_tab_source_url', true)); $item = array( 'title' => wp_strip_all_tags($post->post_title), 'url' => $source_url ?: get_permalink($post_id), 'content' => $this->clean_source_text($post->post_content), 'excerpt' => $this->clean_source_text($post->post_excerpt), 'image' => get_the_post_thumbnail_url($post_id, 'full') ?: '', 'source' => $source_url ? (string)wp_parse_url($source_url, PHP_URL_HOST) : '', 'timestamp' => strtotime($post->post_date_gmt.' UTC') ?: time(), ); $article = null; $content = ''; $words = 0; for ($attempt = 1; $attempt <= 3; $attempt++) { $allow_without_ai = $this->settings['allow_without_ai'] ?? 0; $this->settings['allow_without_ai'] = 0; $article = 1 === $attempt ? $this->generate_article($item) : $this->normalize_article_length($article, $item, $words); $this->settings['allow_without_ai'] = $allow_without_ai; if (is_wp_error($article)) break; $attribution = $this->settings['source_attribution'] ?? 1; if (!$source_url) $this->settings['source_attribution'] = 0; $content = $this->format_article($article, $item); $this->settings['source_attribution'] = $attribution; $words = $this->article_word_count($content); if ($words >= (int)$this->settings['min_words'] && $words <= (int)$this->settings['max_words']) break; } if (is_wp_error($article)) { $this->record_rebuild_failure($post_id, $article->get_error_message()); return; } if ($words < (int)$this->settings['min_words'] || $words > (int)$this->settings['max_words']) { $this->record_rebuild_failure($post_id, 'Generated article failed length validation at '.$words.' words.'); return; } if (!metadata_exists('post', $post_id, '_tab_rebuild_original_content')) { add_post_meta($post_id, '_tab_rebuild_original_content', $post->post_content, true); add_post_meta($post_id, '_tab_rebuild_original_excerpt', $post->post_excerpt, true); } $previous_user = get_current_user_id(); wp_set_current_user(max(1, (int)$post->post_author)); try { $updated = wp_update_post(array( 'ID' => $post_id, 'post_excerpt' => sanitize_textarea_field($article['excerpt'] ?? $post->post_excerpt), 'post_content' => $content, ), true); } finally { wp_set_current_user($previous_user); } if (is_wp_error($updated)) { $this->record_rebuild_failure($post_id, $updated->get_error_message()); return; } clean_post_cache($post_id); $stored_words = $this->article_word_count((string)get_post_field('post_content', $post_id, 'raw')); if ($stored_words < (int)$this->settings['min_words'] || $stored_words > (int)$this->settings['max_words']) { global $wpdb; $wpdb->update($wpdb->posts, array( 'post_content' => (string)get_post_meta($post_id, '_tab_rebuild_original_content', true), 'post_excerpt' => (string)get_post_meta($post_id, '_tab_rebuild_original_excerpt', true), ), array('ID' => $post_id), array('%s', '%s'), array('%d')); clean_post_cache($post_id); $this->record_rebuild_failure($post_id, 'Stored article failed validation at '.$stored_words.' words; original content was restored.'); return; } update_post_meta($post_id, '_tab_rebuild_status', 'complete'); update_post_meta($post_id, '_tab_rebuild_words_before', $original_words); update_post_meta($post_id, '_tab_rebuild_words_after', $stored_words); update_post_meta($post_id, '_tamfis_editorial_rebuild', $batch); update_post_meta($post_id, '_tab_ai_generated', 1); update_post_meta($post_id, '_tab_generated_at', current_time('mysql')); update_post_meta($post_id, '_tab_rebuild_completed_at', current_time('mysql')); delete_post_meta($post_id, '_tab_rebuild_error'); } private function record_rebuild_failure($post_id, $message) { update_post_meta($post_id, '_tab_rebuild_status', 'failed'); update_post_meta($post_id, '_tab_rebuild_error', sanitize_text_field($message)); $this->log('error', 'Rebuild failed for post #'.$post_id.': '.$message); } /** * One-off recovery for the create_post() body-loss bug fixed above * (2026-08-31): published posts left containing only a headline image * because $content was silently emptied before the final save. Unlike * rebuild_existing_post(), this can't treat post_content as usable * source material -- there's no real text left in it to expand -- so * it re-fetches the original article from _tab_source_url via * read_url() (the same enrichment path collect_items() already uses) * and regenerates from that, exactly like a fresh create_post() call, * then overwrites the stub content in place while keeping the post's * existing ID/date/status. */ public function recover_stub_post($post_id) { $this->settings = tab_settings(); $post_id = absint($post_id); $post = get_post($post_id); if (!$post || 'post' !== $post->post_type || 'publish' !== $post->post_status) { return new WP_Error('tab_recover_bad_post', 'Post not found or not a published post.'); } $source_url = esc_url_raw((string)get_post_meta($post_id, '_tab_source_url', true)); if (!$source_url) return new WP_Error('tab_recover_no_source', 'Post has no recorded source URL.'); $fetched = $this->read_url($source_url); if (is_wp_error($fetched)) return $fetched; if (strlen((string)($fetched['content'] ?? '')) < 200) { return new WP_Error('tab_recover_thin_source', 'Re-fetched source article had too little text to regenerate from.'); } $item = array( 'title' => wp_strip_all_tags($post->post_title), 'url' => $source_url, 'content' => $fetched['content'], 'excerpt' => $this->clean_source_text($post->post_excerpt) ?: wp_trim_words($fetched['content'], 40), 'image' => $fetched['image'] ?? '', 'video' => $fetched['video'] ?? '', 'source' => (string)wp_parse_url($source_url, PHP_URL_HOST), 'timestamp' => strtotime($post->post_date_gmt.' UTC') ?: time(), ); $target_min_words = max(self::HARD_MIN_WORDS, (int)$this->settings['min_words']); $article = null; $content = ''; $words = 0; for ($attempt = 1; $attempt <= 3; $attempt++) { $article = 1 === $attempt ? $this->generate_article($item) : $this->normalize_article_length($article, $item, $words); if (is_wp_error($article)) break; $content = $this->format_article($article, $item); $words = $this->article_word_count($content); if ($words >= $target_min_words && $words <= (int)$this->settings['max_words']) break; } if (is_wp_error($article)) return $article; if ($words < $target_min_words || $words > (int)$this->settings['max_words']) { return new WP_Error('tab_recover_length_failed', 'Regenerated article failed length validation at '.$words.' words.'); } // The stub post already has whatever image attach_image() managed // originally (that half of create_post() worked correctly -- only // the body text was lost) -- re-attach only if it's genuinely // missing a featured image. $attachment_id = get_post_thumbnail_id($post_id); if (!$attachment_id && !empty($item['image']) && !empty($this->settings['download_images'])) { $new_attachment = $this->attach_image($post_id, $item['image'], $article['title']); if (!is_wp_error($new_attachment)) $attachment_id = $new_attachment; } if (!$attachment_id) { $query = !empty($article['image_query']) ? $article['image_query'] : $article['title']; $stock_url = $this->fetch_stock_photo($query); if ($stock_url) $attachment_id = $this->attach_image($post_id, $stock_url, $article['title']); } if ($attachment_id && !empty($this->settings['inline_images']) && false === strpos($content, 'tab-article-image')) { $content = $this->insert_inline_image($content, $attachment_id, $article['title']); } $updated = wp_update_post(array( 'ID' => $post_id, 'post_excerpt' => sanitize_textarea_field($article['excerpt'] ?? $post->post_excerpt), 'post_content' => $content, ), true); if (is_wp_error($updated)) return $updated; clean_post_cache($post_id); $stored_words = $this->article_word_count((string)get_post_field('post_content', $post_id, 'raw')); // HARD_MIN_WORDS guard: this recovery must not leave the post // published unless it now genuinely has both a real body and a // real featured image -- same bar create_post() enforces for new // publishes. if (!$this->ready_to_publish($post_id, $stored_words)) { wp_update_post(array('ID' => $post_id, 'post_status' => 'draft')); update_post_meta($post_id, '_tab_needs_image', 1); return new WP_Error( 'tab_recover_stored_mismatch', 'Stored content was '.$stored_words.' words with '.($attachment_id ? 'an' : 'no').' image after save; reverted to draft.' ); } update_post_meta($post_id, '_tab_stub_recovered_at', current_time('mysql')); $this->log('success', 'Recovered stub post #'.$post_id.' ('.$stored_words.' words)', $source_url); return $stored_words; } private function article_word_count($html) { $text = html_entity_decode(wp_strip_all_tags(strip_shortcodes((string)$html), true), ENT_QUOTES | ENT_HTML5, 'UTF-8'); return str_word_count(preg_replace('/[^\p{L}\p{N}\x27’-]+/u', ' ', $text)); } public function run($limit = null, $campaign = null) { if (get_transient('tab_import_lock')) return new WP_Error('tab_locked', 'Another import is already running.'); set_transient('tab_import_lock', 1, 10 * MINUTE_IN_SECONDS); $this->settings = tab_settings(); $limit = max(1, min(25, (int)($limit ?: $this->settings['posts_per_run']))); $result = array('seen' => 0, 'created' => 0, 'skipped' => 0, 'errors' => array(), 'posts' => array()); // A campaign's sources are already niche-scoped by discovery, so the // free-text topic filter would only reject items redundantly (or // wrongly, since it was written against the site's global keywords). $keywords_backup = $this->settings['keywords']; if ($campaign) $this->settings['keywords'] = ''; try { $items = $this->collect_items($limit * 5, $campaign); foreach ($items as $item) { if ($result['created'] >= $limit) break; $result['seen']++; if (!$this->item_allowed($item) || $this->is_duplicate($item)) { $result['skipped']++; continue; } $created = $this->create_post($item, $campaign); if (is_wp_error($created)) { $result['errors'][] = $created->get_error_message(); $this->log('error', $created->get_error_message(), $item['url'] ?? ''); } else { $result['created']++; $result['posts'][] = $created; } } } catch (Throwable $e) { $result['errors'][] = $e->getMessage(); $this->log('error', $e->getMessage()); } $this->settings['keywords'] = $keywords_backup; delete_transient('tab_import_lock'); update_option('tab_last_run', array('time' => current_time('mysql'), 'result' => $result), false); return $result; } /** * Fully unattended entry point for wp-cron. Instead of firing on every * tick at a fixed posts-per-run rate (which either floods the site or * requires a human to keep tuning the schedule), this holds a randomized * daily target (settings-bound min/max) and spreads it probabilistically * across the remaining ticks of the day so posting cadence stays natural * without any manual scheduling. */ public function run_autopilot() { $this->settings = tab_settings(); if (empty($this->settings['enabled']) || get_transient('tab_import_lock')) return; $today = current_time('Y-m-d'); $quota = (array)get_option('tab_daily_quota', array()); if (($quota['date'] ?? '') !== $today) { $min = max(1, (int)$this->settings['daily_posts_min']); $max = max($min, (int)$this->settings['daily_posts_max']); $quota = array('date' => $today, 'target' => wp_rand($min, $max), 'created' => 0); update_option('tab_daily_quota', $quota, false); } $remaining = (int)$quota['target'] - (int)$quota['created']; if ($remaining <= 0) return; $schedule_slug = wp_get_schedule('tab_run_scheduled_import'); $schedules = wp_get_schedules(); $interval = ($schedule_slug && isset($schedules[$schedule_slug])) ? (int)$schedules[$schedule_slug]['interval'] : HOUR_IN_SECONDS; $now = current_time('timestamp'); $seconds_left = max($interval, strtotime('tomorrow', $now) - $now); $ticks_left = max(1, (int)ceil($seconds_left / $interval)); $probability = min(1, $remaining / $ticks_left); if ((wp_rand(1, 1000000) / 1000000) > $probability) return; $campaign = $this->pick_campaign(); $result = $this->run(1, $campaign); if (!is_wp_error($result) && ($result['created'] ?? 0) > 0) { $quota['created'] = (int)$quota['created'] + (int)$result['created']; update_option('tab_daily_quota', $quota, false); } } /** * Round-robins across the site's own categories so every existing niche * gets fresh AI-written coverage over time instead of one category * absorbing every autopilot post. */ private function pick_campaign() { if (empty($this->settings['auto_discover_sources'])) return null; $campaigns = $this->get_campaigns(); if (!$campaigns) return null; usort($campaigns, function($a, $b) { return ($a['last_used'] ?? 0) <=> ($b['last_used'] ?? 0); }); $campaign = $campaigns[0]; $all = (array)get_option('tab_campaigns', array()); if (isset($all[$campaign['term_id']])) { $all[$campaign['term_id']]['last_used'] = time(); update_option('tab_campaigns', $all, false); } return $campaign; } /** * One "campaign" per existing WordPress category: a cached list of * AI-discovered sources for that niche, refreshed periodically. This is * what lets the robot post without a human ever filling in a source * list — the site's own taxonomy defines the niches. */ /** Borrowed from Newsomatic ("random author"): rotate posts across a pool of author user IDs. */ private function pick_author() { $ids = array(); foreach (preg_split('/[,\s]+/', (string)($this->settings['author_ids'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) as $id) { $id = absint($id); if ($id > 0 && get_userdata($id)) $ids[] = $id; } if ($ids) return $ids[array_rand($ids)]; return max(1, (int)$this->settings['author_id']); } /** * Borrowed from Newsomatic (per-category rules): owner-chosen feeds per category. One line per category: * "Category name | https://feed-one | https://feed-two". Matching is by category name, case-insensitive. */ private function manual_category_sources() { $map = array(); foreach (preg_split('/\R/', (string)($this->settings['category_feeds'] ?? '')) as $line) { $line = trim($line); if ($line === '' || $line[0] === '#') continue; $parts = preg_split('/\s*\|\s*/', $line); $name = strtolower(trim((string)array_shift($parts))); $urls = array(); foreach ($parts as $part) { foreach (preg_split('/[\s,]+/', $part, -1, PREG_SPLIT_NO_EMPTY) as $u) { $u = esc_url_raw($u); if ($u && wp_http_validate_url($u)) $urls[] = $u; } } if ($name !== '' && $urls) $map[$name] = array_values(array_unique(array_merge($map[$name] ?? array(), $urls))); } return $map; } private function get_campaigns() { $stored = (array)get_option('tab_campaigns', array()); $categories = get_categories(array('hide_empty' => false)); $excluded_list = array_filter(array_map('trim', explode(',', strtolower((string)($this->settings['excluded_niches'] ?? ''))))); $refresh_after = max(1, (int)($this->settings['campaign_refresh_days'] ?? 7)) * DAY_IN_SECONDS; $out = array(); $discovered_this_run = false; $manual = $this->manual_category_sources(); foreach ($categories as $cat) { $name = html_entity_decode($cat->name, ENT_QUOTES, 'UTF-8'); if ($this->is_niche_excluded($name, $excluded_list)) continue; if (!empty($manual[strtolower($name)])) { // Owner-chosen feeds win over AI discovery for this category. $prev = $stored[$cat->term_id] ?? array(); $entry = array('sources' => $manual[strtolower($name)], 'discovered_at' => time(), 'last_used' => (int)($prev['last_used'] ?? 0), 'manual' => 1); if (($prev['sources'] ?? null) !== $entry['sources'] || empty($prev['manual'])) { $stored[$cat->term_id] = $entry; update_option('tab_campaigns', $stored, false); } $out[] = array_merge($entry, array('term_id' => $cat->term_id, 'name' => $name)); continue; } $entry = $stored[$cat->term_id] ?? null; $stale = !$entry || empty($entry['sources']) || (time() - (int)($entry['discovered_at'] ?? 0)) > $refresh_after; if ($stale) { // Cap AI-discovery calls to one fresh/stale category per run // so a large taxonomy can't spike API usage in a single tick. if ($discovered_this_run) { if ($entry) $out[] = array_merge($entry, array('term_id' => $cat->term_id, 'name' => $name)); continue; } $sources = $this->discover_sources_for_niche($name); if (is_wp_error($sources)) { $this->log('warning', 'Source discovery failed for "'.$name.'": '.$sources->get_error_message()); if ($entry) $out[] = array_merge($entry, array('term_id' => $cat->term_id, 'name' => $name)); continue; } $entry = array('sources' => $sources, 'discovered_at' => time(), 'last_used' => (int)($entry['last_used'] ?? 0)); $stored[$cat->term_id] = $entry; update_option('tab_campaigns', $stored, false); $discovered_this_run = true; $this->log('success', 'Discovered '.count($sources).' sources for niche "'.$name.'".'); } $out[] = array_merge($entry, array('term_id' => $cat->term_id, 'name' => $name)); } return $out; } /** * Hard-coded floor beneath the admin-configurable exclusion list: no * site setting can re-enable auto-discovery/auto-posting for a category * whose name suggests minors, regardless of context. */ private function is_niche_excluded($name, $excluded_list = array()) { $lower = strtolower($name); $blocked = array('teen','teens','child','children','kid','kids','minor','minors','loli','shota','young girl','young boy','schoolgirl','school girl','underage','preteen'); foreach ($blocked as $term) if (strpos($lower, $term) !== false) return true; foreach ($excluded_list as $term) if ($term !== '' && strpos($lower, $term) !== false) return true; return false; } private function discover_sources_for_niche($name) { $providers = $this->configured_providers(); if (!$providers) return new WP_Error('tab_no_key', 'AI API key is not configured.'); $system = 'You are a careful research assistant that only returns valid JSON. Never invent URLs; only list real, well-known websites or RSS feeds you are confident exist.'; $prompt = 'List up to 8 real, authoritative, publicly accessible RSS feed URLs (preferred) or news/article section URLs covering the topic/niche "'.$name.'". Return JSON only: {"sources": ["https://...", ...]}.'; $errors = array(); foreach ($providers as $provider) { $data = $this->request_ai_json($provider, $system, $prompt); if (is_wp_error($data)) { $errors[] = $provider['label'].': '.$data->get_error_message(); continue; } $urls = array(); foreach ((array)($data['sources'] ?? array()) as $url) { $url = esc_url_raw(trim((string)$url)); if ($url && wp_http_validate_url($url)) $urls[] = $url; } $urls = array_values(array_unique($urls)); if ($urls) return $urls; $errors[] = $provider['label'].': no valid URLs returned'; } return new WP_Error('tab_discovery_failed', 'Niche source discovery failed. '.implode(' | ', $errors)); } private function collect_items($limit, $campaign = null) { $sources = $campaign['sources'] ?? array_filter(array_map('trim', preg_split('/\R/', (string)$this->settings['sources']))); $items = array(); foreach ($sources as $source) { if (count($items) >= $limit) break; if (!wp_http_validate_url($source)) continue; $feed_items = $this->read_feed($source, $limit - count($items)); if (is_wp_error($feed_items) || !$feed_items) { $web_items = $this->read_website($source, $limit - count($items)); if (!is_wp_error($web_items)) $items = array_merge($items, $web_items); else $this->log('warning', $web_items->get_error_message(), $source); } else { $items = array_merge($items, $feed_items); } } usort($items, function($a, $b) { return ($b['timestamp'] ?? 0) <=> ($a['timestamp'] ?? 0); }); return array_slice($items, 0, $limit); } private function read_website($url, $limit) { $page = $this->fetch_html($url); if (is_wp_error($page)) return $page; $single = $this->parse_article($url, $page['body']); if (empty($this->settings['scrape_enabled'])) return is_wp_error($single) ? $single : array($single); $links = $this->discover_article_links($url, $page['body']); if (!$links) return is_wp_error($single) ? new WP_Error('tab_no_links', 'No feed or article links were found on this source.') : array($single); $out = array(); $inspect = min(count($links), (int)$this->settings['scrape_links_per_source'], max($limit * 3, $limit)); foreach (array_slice($links, 0, $inspect) as $link) { if (count($out) >= $limit) break; $response = $this->fetch_html($link); if (is_wp_error($response)) continue; $article = $this->parse_article($link, $response['body']); if (!is_wp_error($article) && strlen($article['content']) >= 300) $out[] = $article; } if (!$out && !is_wp_error($single)) $out[] = $single; return $out ?: new WP_Error('tab_scrape_empty', 'No usable articles were discovered on the source page.'); } private function fetch_html($url) { $response = wp_safe_remote_get($url, array('timeout' => 20, 'redirection' => 3, 'limit_response_size' => 2 * MB_IN_BYTES, 'headers' => array('Accept' => 'text/html,application/xhtml+xml'), 'user-agent' => 'TamfisAutoBlog/1.1 (+'.home_url('/').')')); if (is_wp_error($response)) return $response; $code = wp_remote_retrieve_response_code($response); if ($code >= 400) return new WP_Error('tab_http', 'Source returned HTTP '.$code); $type = strtolower((string)wp_remote_retrieve_header($response, 'content-type')); if ($type && strpos($type, 'html') === false) return new WP_Error('tab_not_html', 'Source is not an HTML page.'); $body = wp_remote_retrieve_body($response); return strlen($body) < 200 ? new WP_Error('tab_empty', 'Source page contained no usable content.') : array('body' => $body); } private function discover_article_links($base_url, $html) { if (!class_exists('DOMDocument')) return array(); libxml_use_internal_errors(true); $dom = new DOMDocument(); $dom->loadHTML(''.$html, LIBXML_NOERROR | LIBXML_NOWARNING); $base_host = strtolower((string)wp_parse_url($base_url, PHP_URL_HOST)); $ranked = array(); foreach ($dom->getElementsByTagName('a') as $anchor) { $href = trim($anchor->getAttribute('href')); $text = trim(preg_replace('/\s+/u', ' ', $anchor->textContent)); $url = $this->absolute_url($href, $base_url); if (!$url || strtolower((string)wp_parse_url($url, PHP_URL_HOST)) !== $base_host || strlen($text) < 12) continue; $path = (string)wp_parse_url($url, PHP_URL_PATH); if ($path === '/' || preg_match('~/(tag|author|category|search|login|contact|about|privacy|terms|feed|page)/|\.(jpg|jpeg|png|gif|webp|pdf|zip)$~i', $path)) continue; $score = min(80, strlen($text)); if (preg_match('~/20\d{2}/(?:0?[1-9]|1[0-2])/~', $path)) $score += 50; if (preg_match('~/(news|blog|article|story|technology|business)/~i', $path)) $score += 25; $score += min(30, substr_count(trim($path, '/'), '/') * 8); $ranked[untrailingslashit($url)] = max($ranked[untrailingslashit($url)] ?? 0, $score); } arsort($ranked); return array_keys($ranked); } private function absolute_url($href, $base) { if (!$href || preg_match('~^(#|mailto:|tel:|javascript:)~i', $href)) return ''; if (strpos($href, '//') === 0) $href = (wp_parse_url($base, PHP_URL_SCHEME) ?: 'https').':'.$href; if (wp_http_validate_url($href)) return esc_url_raw($href); $parts = wp_parse_url($base); if (empty($parts['host'])) return ''; $origin = ($parts['scheme'] ?? 'https').'://'.$parts['host'].(isset($parts['port']) ? ':'.$parts['port'] : ''); if ($href[0] !== '/') { $directory = isset($parts['path']) ? trailingslashit(dirname($parts['path'])) : '/'; $href = $directory.$href; } $segments = array(); foreach (explode('/', (string)wp_parse_url($href, PHP_URL_PATH)) as $segment) { if ($segment === '' || $segment === '.') continue; if ($segment === '..') array_pop($segments); else $segments[] = $segment; } $query = wp_parse_url($href, PHP_URL_QUERY); return esc_url_raw($origin.'/'.implode('/', $segments).($query ? '?'.$query : '')); } private function read_feed($url, $limit) { require_once ABSPATH . WPINC . '/feed.php'; $feed = fetch_feed($url); if (is_wp_error($feed)) return $feed; $out = array(); foreach ($feed->get_items(0, $limit) as $entry) { $link = esc_url_raw($entry->get_permalink()); if (!$link) continue; $content = $entry->get_content() ?: $entry->get_description(); $image = $this->extract_image($content); if (!$image) $image = $this->enclosure_image($entry); if ($image) $image = $this->absolute_url($image, $link) ?: $image; $clean_content = $this->clean_source_text($content); $video = $this->extract_video($content); // Feeds commonly contain only a teaser and no media. Enrich from // the canonical article so topic filters and image enforcement // operate on the article itself, not incomplete feed metadata. if (!$image || !$video || !empty($this->settings['keywords']) || strlen($clean_content) < 500) { $full = $this->read_url($link); if (!is_wp_error($full)) { if (!empty($full['content'])) $clean_content = $full['content']; if (!$image && !empty($full['image'])) $image = $full['image']; if (!$video && !empty($full['video'])) $video = $full['video']; } } $out[] = array( 'title' => wp_strip_all_tags($entry->get_title()), 'url' => $link, 'content' => $clean_content, 'excerpt' => $this->clean_source_text($entry->get_description()), 'image' => esc_url_raw($image), 'video' => esc_url_raw($video), 'source' => wp_parse_url($link, PHP_URL_HOST), 'timestamp' => (int)$entry->get_date('U'), ); } return $out; } private function read_url($url) { $response = $this->fetch_html($url); return is_wp_error($response) ? $response : $this->parse_article($url, $response['body']); } private function parse_article($url, $html) { libxml_use_internal_errors(true); $dom = new DOMDocument(); $dom->loadHTML(''.$html, LIBXML_NOERROR | LIBXML_NOWARNING); $xpath = new DOMXPath($dom); $title = $this->xpath_value($xpath, '//meta[@property="og:title"]/@content') ?: $this->xpath_value($xpath, '//title'); $image = $this->xpath_value($xpath, '//meta[@property="og:image:secure_url"]/@content') ?: $this->xpath_value($xpath, '//meta[@property="og:image"]/@content') ?: $this->xpath_value($xpath, '//meta[@name="twitter:image"]/@content') ?: $this->xpath_value($xpath, '//meta[@name="twitter:image:src"]/@content') ?: $this->jsonld_image($xpath) ?: $this->xpath_value($xpath, '//article//img[1]/@data-src') ?: $this->xpath_value($xpath, '//article//img[1]/@src') ?: $this->first_srcset_url($xpath, '//article//source[1]/@srcset') ?: $this->first_srcset_url($xpath, '//article//img[1]/@srcset') ?: $this->xpath_value($xpath, '//main//img[1]/@data-src') ?: $this->xpath_value($xpath, '//main//img[1]/@src') ?: $this->first_srcset_url($xpath, '//main//source[1]/@srcset') ?: $this->first_srcset_url($xpath, '//main//img[1]/@srcset'); $description = $this->xpath_value($xpath, '//meta[@name="description"]/@content') ?: $this->xpath_value($xpath, '//meta[@property="og:description"]/@content'); $published = $this->xpath_value($xpath, '//meta[@property="article:published_time"]/@content') ?: $this->xpath_value($xpath, '//time/@datetime'); $video = $this->xpath_value($xpath, '(//iframe[contains(@src,"youtube.com/embed") or contains(@src,"youtu.be") or contains(@src,"player.vimeo.com")])[1]/@src'); foreach ($xpath->query('//script|//style|//nav|//footer|//header|//aside|//form') as $node) $node->parentNode->removeChild($node); $article = $xpath->query('//article')->item(0) ?: $xpath->query('//main')->item(0) ?: $dom->getElementsByTagName('body')->item(0); $text = $article ? $article->textContent : ''; return array('title' => sanitize_text_field($title), 'url' => esc_url_raw($url), 'content' => $this->clean_source_text($text), 'excerpt' => sanitize_textarea_field($description), 'image' => esc_url_raw($this->absolute_url($image, $url) ?: $image), 'video' => esc_url_raw($this->absolute_url($video, $url) ?: $video), 'source' => wp_parse_url($url, PHP_URL_HOST), 'timestamp' => $published && strtotime($published) ? strtotime($published) : time()); } private function extract_video($html) { if (preg_match('~]+src=["\']([^"\']*(?:youtube\.com/embed|youtu\.be|player\.vimeo\.com)[^"\']*)["\']~i', (string)$html, $m)) { return html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'); } return ''; } private function normalize_video_url($url) { if (preg_match('~youtube\.com/embed/([A-Za-z0-9_-]+)~i', $url, $m)) return 'https://www.youtube.com/watch?v='.$m[1]; if (preg_match('~youtu\.be/([A-Za-z0-9_-]+)~i', $url, $m)) return 'https://www.youtu.be/'.$m[1]; if (preg_match('~player\.vimeo\.com/video/(\d+)~i', $url, $m)) return 'https://vimeo.com/'.$m[1]; return $url; } private function xpath_value($xpath, $query) { $nodes = $xpath->query($query); return ($nodes && $nodes->length) ? trim($nodes->item(0)->nodeValue) : ''; } private function first_srcset_url($xpath, $query) { $value = $this->xpath_value($xpath, $query); if (!$value) return ''; $first = trim(explode(',', $value)[0]); $first = preg_split('/\s+/', $first)[0] ?? ''; return trim($first); } private function jsonld_image($xpath) { foreach ($xpath->query('//script[@type="application/ld+json"]') as $node) { $data = json_decode((string)$node->textContent, true); if (!is_array($data)) continue; $entries = isset($data[0]) ? $data : array($data); foreach ($entries as $entry) { if (!is_array($entry) || empty($entry['image'])) continue; $image = $entry['image']; if (is_string($image)) return $image; if (is_array($image)) { if (isset($image['url'])) return (string)$image['url']; $first = reset($image); if (is_string($first)) return $first; if (is_array($first) && isset($first['url'])) return (string)$first['url']; } } } return ''; } private function clean_source_text($value) { $value = html_entity_decode(wp_strip_all_tags((string)$value, true), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $value = preg_replace('/\s+/u', ' ', $value); return mb_substr(trim($value), 0, 16000); } private function extract_image($html) { if (preg_match('/]+(?:data-src|data-lazy-src|src)=["\']([^"\']+)/i', (string)$html, $m)) return html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'); return ''; } /** * Many feeds (NYT, etc.) publish or * without a MIME "type" attribute, so a strict type-sniff * silently drops a perfectly usable image and the whole item gets discarded * downstream. Accept enclosures/media by medium, MIME type, or URL extension. */ private function enclosure_image($entry) { $candidates = array(); if (method_exists($entry, 'get_enclosure')) { $enclosure = $entry->get_enclosure(); if ($enclosure) { $candidates[] = array('link' => $enclosure->get_link(), 'type' => (string)$enclosure->get_type(), 'medium' => method_exists($enclosure, 'get_medium') ? (string)$enclosure->get_medium() : ''); if (method_exists($enclosure, 'get_thumbnail') && $enclosure->get_thumbnail()) { $candidates[] = array('link' => $enclosure->get_thumbnail(), 'type' => '', 'medium' => 'image'); } } } if (method_exists($entry, 'get_thumbnail')) { $thumbnail = $entry->get_thumbnail(); if (is_array($thumbnail) && !empty($thumbnail['url'])) $candidates[] = array('link' => $thumbnail['url'], 'type' => '', 'medium' => 'image'); } foreach ($candidates as $candidate) { $link = trim((string)$candidate['link']); if (!$link) continue; $is_image = strpos($candidate['type'], 'image/') === 0 || strtolower($candidate['medium']) === 'image' || preg_match('/\.(jpe?g|png|gif|webp|avif)(\?.*)?$/i', $link); if ($is_image) return $link; } return ''; } /** Site-level gate (e.g. the Finima content-mix mu-plugin) on top of the plugin's own relevance check. */ private function item_allowed($item) { return apply_filters('tab_item_allowed', $this->item_allowed_base($item), $item); } private function item_allowed_base($item) { // Borrowed from Newsomatic ("skip old posts"): ignore source articles older than N days (0 = off). $max_age = (int)($this->settings['max_item_age_days'] ?? 0); $ts = (int)($item['timestamp'] ?? 0); if ($max_age > 0 && $ts > 946684800 && $ts < time() - $max_age * DAY_IN_SECONDS) return false; if (empty($item['title']) || empty($item['url']) || strlen($item['content'] ?? '') < 120) return false; $haystack = $this->normalise_topic_text(($item['title'] ?? '').' '.($item['excerpt'] ?? '').' '.($item['content'] ?? '').' '.($item['source'] ?? '')); $excluded = array_filter(array_map('trim', preg_split('/[,\n]+/', strtolower((string)$this->settings['excluded_words'])))); foreach ($excluded as $word) if ($word !== '' && strpos($haystack, $word) !== false) return false; $keywords = array_filter(array_map('trim', preg_split('/[,\n]+/', (string)$this->settings['keywords']))); if (!$keywords) return true; foreach ($keywords as $topic) { $topic = $this->normalise_topic_text($topic); if ($topic !== '' && strpos($haystack, $topic) !== false) return true; foreach (array_filter(explode(' ', $topic), function($term) { return strlen($term) >= 4; }) as $term) { $stem = preg_replace('/(ies|ing|ed|es|s)$/', '', $term); if (strlen($stem) >= 4 && preg_match('/\\b'.preg_quote($stem, '/').'[a-z]*\\b/', $haystack)) return true; } } return false; } private function normalise_topic_text($value) { $value = strtolower(remove_accents(wp_strip_all_tags((string)$value))); return trim(preg_replace('/[^a-z0-9]+/', ' ', $value)); } private function is_duplicate($item) { $key = hash('sha256', strtolower(untrailingslashit($item['url']))); $ids = get_posts(array('post_type' => 'post', 'post_status' => 'any', 'meta_key' => '_tab_source_hash', 'meta_value' => $key, 'fields' => 'ids', 'posts_per_page' => 1)); if ($ids) return true; return (bool)get_page_by_title($item['title'], OBJECT, 'post'); } private function create_post($item, $campaign = null) { // A single AI pass often lands short of the SEO word-count floor. // Retry with an explicit condense/expand pass (same mechanism the // bulk rebuild pipeline uses) until the rendered length actually // lands in range, instead of publishing whatever came back first. $article = null; $content = ''; $words = 0; $target_min_words = max(self::HARD_MIN_WORDS, (int)$this->settings['min_words']); for ($attempt = 1; $attempt <= 3; $attempt++) { $article = 1 === $attempt ? $this->generate_article($item) : $this->normalize_article_length($article, $item, $words); if (is_wp_error($article)) break; $content = $this->format_article($article, $item); $words = $this->article_word_count($content); if ($words >= $target_min_words && $words <= (int)$this->settings['max_words']) break; } if (is_wp_error($article)) return $article; if ($words < $target_min_words || $words > (int)$this->settings['max_words']) { return new WP_Error('tab_length_failed', 'Generated article failed length validation at '.$words.' words.'); } $desired_status = in_array($this->settings['post_status'], array('draft','pending','publish'), true) ? $this->settings['post_status'] : 'draft'; // require_images/unattended_mode used to be able to bypass this // entirely and publish with no image at all -- see ready_to_publish(). $require_images = true; $categories = array_filter(array((int)$this->settings['category_id'])); if (!empty($this->settings['auto_category'])) { // New responses use `categories`; keep accepting the old singular // key so drafts produced by an earlier plugin version still work. $suggested = $article['categories'] ?? ($article['category'] ?? array()); $matched = $this->resolve_categories($suggested); if ($matched) $categories = $matched; } // A campaign run is already scoped to one of the site's own // categories by source discovery, so the post belongs there // regardless of what the AI classifier guessed. if ($campaign && !empty($campaign['term_id'])) { $categories[] = (int)$campaign['term_id']; $categories = array_values(array_unique(array_filter($categories))); } // The AI rewrite already happened (and was paid for). A missing or // undownloadable image must never discard that work — the post is // always kept, as a draft flagged _tab_needs_image, so an editor can // attach an image and publish it later instead of losing it silently. $post_id = wp_insert_post(array( 'post_type' => 'post', 'post_status' => 'draft', 'post_title' => sanitize_text_field($article['title']), 'post_excerpt' => sanitize_textarea_field($article['excerpt']), 'post_content' => $content, 'post_author' => $this->pick_author(), 'post_category' => $categories, 'meta_input' => array( '_tab_source_url' => esc_url_raw($item['url']), '_tab_source_name' => sanitize_text_field($item['source']), '_tab_source_hash' => hash('sha256', strtolower(untrailingslashit($item['url']))), '_tab_ai_generated' => 1, '_tab_generated_at' => current_time('mysql'), '_tab_campaign_name' => $campaign['name'] ?? '', ), ), true); if (is_wp_error($post_id)) return $post_id; if (!empty($article['tags'])) wp_set_post_tags($post_id, array_map('sanitize_text_field', (array)$article['tags']), false); $image_url = $item['image'] ?? ''; if (!$image_url) { $query = !empty($article['image_query']) ? $article['image_query'] : $article['title']; $image_url = $this->fetch_stock_photo($query); } $attachment_id = 0; if (!empty($this->settings['download_images']) && !empty($image_url)) { $attachment_id = $this->attach_image($post_id, $image_url, $article['title']); } // If the source image itself failed to attach (broken link, hotlink // protection), still try a stock photo before giving up on the image. if ((!$attachment_id || is_wp_error($attachment_id)) && !empty($item['image']) && !empty($this->settings['download_images'])) { $query = !empty($article['image_query']) ? $article['image_query'] : $article['title']; $stock_url = $this->fetch_stock_photo($query); if ($stock_url) $attachment_id = $this->attach_image($post_id, $stock_url, $article['title']); } $has_image = $attachment_id && !is_wp_error($attachment_id); if (!$has_image && $require_images) { $reason = is_wp_error($attachment_id) ? 'the source image failed to download ('.$attachment_id->get_error_message().')' : 'no usable source image was found'; // HARD_MIN_WORDS guard: unattended_mode used to bypass this and // publish anyway with no image at all. It no longer can -- a // post without a real featured image always stays a draft, // flagged for a human (or create_draft_completion_batch) to // finish, regardless of that setting. update_post_meta($post_id, '_tab_needs_image', 1); $this->log('warning', 'Post #'.$post_id.' kept as a draft pending an image because '.$reason.'.', $item['url']); do_action('tab_post_created', $post_id, $item, $article); return $post_id; } // Reuse the already-validated $content from the retry loop above // instead of re-reading it back via get_post_field() -- this used // to intermittently come back empty here (confirmed live: roughly // half of new posts published as a bare image with the real // 900-1200 word article silently discarded, even though the // plugin's own log showed a successful AI generation for every one // of them), so a post could ship with the figure/caption from // insert_inline_image() below and nothing else. The value computed // and word-count-validated earlier in this same function call is // never stale, so there's no need to round-trip through the DB at // all before this first save. if ($has_image && !empty($this->settings['inline_images'])) { $content = $this->insert_inline_image($content, $attachment_id, $article['title']); } // Final gate, independent of everything above: never let this call // actually set post_status to publish/pending unless the HARD_MIN_WORDS // guard is satisfied. if ('draft' !== $desired_status && !$this->ready_to_publish($post_id, $words)) { $desired_status = 'draft'; update_post_meta($post_id, '_tab_needs_image', 1); } $updated = wp_update_post(array('ID'=>$post_id, 'post_content'=>$content, 'post_status'=>$desired_status), true); if (is_wp_error($updated)) { // Keep the post as a draft rather than deleting already-generated content. $this->log('warning', 'Post #'.$post_id.' kept as a draft; could not set status: '.$updated->get_error_message(), $item['url']); do_action('tab_post_created', $post_id, $item, $article); return $post_id; } delete_post_meta($post_id, '_tab_needs_image'); $this->log('success', 'Created post #'.$post_id.': '.$article['title'], $item['url']); do_action('tab_post_created', $post_id, $item, $article); return $post_id; } private function generate_article($item, $length_emphasis = false) { $providers = $this->configured_providers(); if (!$providers) { if (empty($this->settings['allow_without_ai'])) return new WP_Error('tab_no_key', 'AI API key is not configured.'); return $this->fallback_article($item); } $system = $this->settings['system_prompt'] ?: 'You are a careful newsroom editor. Create original reporting-style analysis from supplied source notes. Never invent facts, quotes, statistics or sources. Do not copy source phrasing. Clearly distinguish facts from analysis. Return one complete valid JSON object only. Do not ask questions, request confirmation, mention files, or add any text outside the JSON object.'; $minimum = (int)$this->settings['min_words']; $maximum = (int)$this->settings['max_words']; $prompt_minimum = min($maximum, $minimum + ($length_emphasis ? 200 : 100)); $length_note = $length_emphasis ? "A prior draft was too short. Develop the supplied context more fully and do not return fewer than {$prompt_minimum} words." : "Aim above the validation floor and do not return fewer than {$prompt_minimum} words."; $category_note = !empty($this->settings['auto_category']) ? "\nClassify this article into one to three relevant EXISTING site categories. Return \"categories\" as an array containing only exact names from this allowed list: ".implode(', ', $this->available_category_names()).". Do not invent or create a category. Also return \"image_query\" as 2-4 plain keywords describing the best stock photo to illustrate this article (subject only, no site names)." : "\nAlso return \"image_query\" as 2-4 plain keywords describing the best stock photo to illustrate this article (subject only, no site names)."; $prompt = "Create an original, useful article in {$this->settings['language']} based only on the source notes below.\nEditorial voice: {$this->settings['editorial_voice']}\nRequired rendered length: {$minimum}-{$maximum} words. {$length_note}\nUse descriptive headings, concise paragraphs, natural topic language, key takeaways and a useful conclusion without keyword stuffing.\nReturn JSON keys: title, excerpt, introduction, sections (array of objects with heading and paragraphs array), key_takeaways (array), conclusion, tags (array).{$category_note}\nSource title: {$item['title']}\nSource URL: {$item['url']}\nSource notes:\n{$item['content']}"; $errors = array(); foreach ($providers as $provider) { $article = $this->request_ai($provider, $system, $prompt); if (!is_wp_error($article)) { $this->log('success', 'AI provider used: '.$provider['label'], $item['url']); return $article; } $errors[] = $provider['label'].': '.$article->get_error_message(); $this->log('warning', 'AI failover from '.$provider['label'].': '.$article->get_error_message()); } if (!empty($this->settings['allow_without_ai'])) return $this->fallback_article($item); return new WP_Error('tab_ai_all_failed', 'All configured AI providers failed. '.implode(' | ', $errors)); } private function normalize_article_length($draft, $item, $draft_words) { if (!is_array($draft)) return new WP_Error('tab_ai_draft', 'The article draft length could not be normalized.'); $providers = $this->configured_providers(); if (!$providers) return new WP_Error('tab_no_key', 'AI API key is not configured.'); $minimum = max((int)$this->settings['min_words'], 1000); $maximum = min((int)$this->settings['max_words'], 1100); if ($maximum < $minimum) $minimum = (int)$this->settings['min_words']; $direction = $draft_words > (int)$this->settings['max_words'] ? 'condense' : 'expand'; $system = 'You are a careful newsroom editor. Adjust the supplied draft using only the supplied source notes. Never invent facts, quotes, statistics or sources. Preserve the article topic and return valid JSON only.'; $prompt = "The rendered draft is approximately {$draft_words} words. {$direction} it to a strict {$minimum}-{$maximum} rendered words. Preserve the useful facts and remove repetition when condensing; add supported explanation and context when expanding. Keep descriptive headings, concise paragraphs, natural topic language, key takeaways and a useful conclusion. Return the same JSON keys as the draft, including categories and image_query if present.\nSource notes:\n{$item['content']}\nDraft JSON:\n".wp_json_encode($draft, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $errors = array(); foreach ($providers as $provider) { $article = $this->request_ai($provider, $system, $prompt); if (!is_wp_error($article)) return $article; $errors[] = $provider['label'].': '.$article->get_error_message(); } return new WP_Error('tab_ai_length_failed', 'All configured AI providers failed to normalize the draft length. '.implode(' | ', $errors)); } private function configured_providers() { $out = array(); $connections = (array)($this->settings['ai_connections'] ?? array()); foreach ($connections as $connection) { if (empty($connection['enabled'])) continue; $type = $connection['type'] ?? 'openai'; $model = $connection['model'] ?? ''; $endpoint = $connection['endpoint'] ?? ''; if (!$endpoint) { if ($type === 'gemini') $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/'.rawurlencode($model).':generateContent'; elseif ($type === 'anthropic') $endpoint = 'https://api.anthropic.com/v1/messages'; elseif ($type === 'openrouter') $endpoint = 'https://openrouter.ai/api/v1/chat/completions'; elseif ($type === 'openai') $endpoint = 'https://api.openai.com/v1/chat/completions'; } if (!$endpoint || (empty($connection['key']) && $type !== 'tamfis')) continue; $out[] = array('type'=>in_array($type,array('openai','gemini','anthropic'),true)?$type:'openai','label'=>$connection['label'] ?: ucfirst($type),'endpoint'=>$endpoint,'key'=>$connection['key'] ?? '','model'=>$model); } if ($connections) return $out; // FIX (owner directive, 2026-09-07, cost control): TamfisGPT tried // FIRST, not last. TamfisGPT's own "auto" routing weights NVIDIA // NIM at ~87% of its traffic (a genuinely free tier, not just // cheap) -- see tier_iv_orchestration/config/orchestration.yaml in // that project. Every provider below it is metered per-token and // confirmed live this same day to be intermittently out of credits // (OpenAI, OpenRouter) or otherwise broken (deprecated Gemini // model, a workspace-scoped Anthropic key) -- trying them FIRST // meant paying for (or failing against) exactly the providers this // plugin should be avoiding, on every single article, before ever // reaching the free route. They remain configured as fallbacks for // when TamfisGPT itself is genuinely down, not as the default path. if (!empty($this->settings['tamfis_api_endpoint'])) $out[] = array('type'=>'openai','label'=>'TamfisGPT','endpoint'=>$this->settings['tamfis_api_endpoint'],'key'=>$this->settings['tamfis_api_key'],'model'=>$this->settings['tamfis_model']); if (!empty($this->settings['ai_api_key']) && !empty($this->settings['ai_endpoint'])) $out[] = array('type'=>'openai','label'=>'Primary','endpoint'=>$this->settings['ai_endpoint'],'key'=>$this->settings['ai_api_key'],'model'=>$this->settings['ai_model']); if (!empty($this->settings['gemini_api_key'])) $out[] = array('type'=>'gemini','label'=>'Gemini','endpoint'=>'https://generativelanguage.googleapis.com/v1beta/models/'.rawurlencode($this->settings['gemini_model']).':generateContent','key'=>$this->settings['gemini_api_key'],'model'=>$this->settings['gemini_model']); if (!empty($this->settings['anthropic_api_key'])) $out[] = array('type'=>'anthropic','label'=>'Anthropic','endpoint'=>'https://api.anthropic.com/v1/messages','key'=>$this->settings['anthropic_api_key'],'model'=>$this->settings['anthropic_model']); if (!empty($this->settings['openrouter_api_key'])) $out[] = array('type'=>'openai','label'=>'OpenRouter','endpoint'=>'https://openrouter.ai/api/v1/chat/completions','key'=>$this->settings['openrouter_api_key'],'model'=>$this->settings['openrouter_model']); return $out; } private function request_ai($provider, $system, $prompt) { $data = $this->request_ai_json($provider, $system, $prompt); if (is_wp_error($data)) return $data; return (empty($data['title']) || empty($data['sections'])) ? new WP_Error('tab_ai_format', 'Invalid article JSON returned.') : $data; } private function request_ai_json($provider, $system, $prompt) { $headers = array('Content-Type' => 'application/json'); if ($provider['type'] === 'gemini') { $endpoint = add_query_arg('key', $provider['key'], $provider['endpoint']); $body = array('systemInstruction'=>array('parts'=>array(array('text'=>$system))), 'contents'=>array(array('role'=>'user','parts'=>array(array('text'=>$prompt)))), 'generationConfig'=>array('temperature'=>(float)$this->settings['temperature'],'maxOutputTokens'=>4000,'responseMimeType'=>'application/json')); } elseif ($provider['type'] === 'anthropic') { $endpoint = $provider['endpoint']; $headers['x-api-key'] = $provider['key']; $headers['anthropic-version'] = '2023-06-01'; $body = array('model'=>$provider['model'],'max_tokens'=>6000,'temperature'=>(float)$this->settings['temperature'],'system'=>$system,'messages'=>array(array('role'=>'user','content'=>$prompt))); } else { $endpoint = $provider['endpoint']; if ($provider['key']) $headers['Authorization'] = 'Bearer '.$provider['key']; // Without an explicit cap, some OpenAI-compatible backends (notably // OpenRouter) default max_tokens to the model's full context window, // which can exceed the account's remaining credit and fail the whole // request even though a few thousand tokens would have been plenty. $body = array('temperature'=>(float)$this->settings['temperature'],'max_tokens'=>4000,'response_format'=>array('type'=>'json_object'),'messages'=>array(array('role'=>'system','content'=>$system),array('role'=>'user','content'=>$prompt))); if ($provider['model']) $body['model'] = $provider['model']; } $response = wp_safe_remote_post($endpoint, array('timeout'=>90,'headers'=>$headers,'body'=>wp_json_encode($body))); if (is_wp_error($response)) return new WP_Error('tab_ai_http', $response->get_error_message()); $data = json_decode(wp_remote_retrieve_body($response), true); $code = wp_remote_retrieve_response_code($response); if ($code >= 300) return new WP_Error('tab_ai_api', sanitize_text_field($data['error']['message'] ?? $data['message'] ?? 'HTTP '.$code)); if ($provider['type'] === 'gemini') $json = $data['candidates'][0]['content']['parts'][0]['text'] ?? ''; elseif ($provider['type'] === 'anthropic') $json = $data['content'][0]['text'] ?? ''; else $json = $data['choices'][0]['message']['content'] ?? $data['output_text'] ?? ''; $json = preg_replace('/^```(?:json)?\s*|\s*```$/i', '', trim((string)$json)); $data = json_decode($json, true); // OpenAI-compatible free/NIM models occasionally add a short // preamble or trailing note despite response_format=json_object. // Recover the JSON object instead of discarding an otherwise valid // article response. if (!is_array($data)) { $start = strpos($json, '{'); $end = strrpos($json, '}'); if (false !== $start && false !== $end && $end > $start) { $data = json_decode(substr($json, $start, $end - $start + 1), true); } } return is_array($data) ? $data : new WP_Error('tab_ai_format', 'Invalid JSON returned.'); } /** Category names exposed to the classifier. The model must pick from these. */ private function available_category_names() { $terms = get_terms(array('taxonomy'=>'category', 'hide_empty'=>false, 'number'=>0)); if (is_wp_error($terms) || !$terms) return array('Uncategorized'); return array_map(static function($term) { return $term->name; }, $terms); } /** * Map up to three AI suggestions to categories which already exist on this * WordPress site. This deliberately never calls wp_insert_term(): automatic * blogging should organise content, not slowly fragment the site's taxonomy. */ private function resolve_categories($suggested) { if (is_string($suggested)) $suggested = preg_split('/[,;|]+/', $suggested); $suggested = array_slice(array_filter(array_map('sanitize_text_field', (array)$suggested)), 0, 3); if (!$suggested) return array(); $terms = get_terms(array('taxonomy'=>'category', 'hide_empty'=>false, 'number'=>0)); if (is_wp_error($terms) || !$terms) return array(); $matched = array(); foreach ($suggested as $name) { $needle = $this->normalise_topic_text($name); if (!$needle) continue; $best_id = 0; $best_score = 0; $needle_words = array_values(array_filter(explode(' ', $needle))); foreach ($terms as $term) { $haystack = $this->normalise_topic_text($term->name.' '.$term->slug); if ($needle === $this->normalise_topic_text($term->name) || $needle === $this->normalise_topic_text($term->slug)) { $best_id = (int)$term->term_id; $best_score = 100; break; } $haystack_words = array_values(array_filter(explode(' ', $haystack))); $overlap = count(array_intersect($needle_words, $haystack_words)); $score = $needle_words ? (int)round(100 * $overlap / count($needle_words)) : 0; if ($score > $best_score) { $best_score = $score; $best_id = (int)$term->term_id; } } if ($best_id && $best_score >= 60) $matched[] = $best_id; } return array_values(array_unique($matched)); } /** * Licensed stock photography fallback for when the scraped source has no * usable image. Tries Pexels first, then Pixabay, then AI generation as * a last resort (see generate_ai_image()). All three are tried from * this single method -- every caller in this file already goes * fetch_stock_photo() -> attach_image($post_id, $url, ...), so adding * the new fallback here means every call site gets it automatically, * with zero risk of missing one and zero duplicated fallback logic. */ private function fetch_stock_photo($query) { $query = trim(wp_strip_all_tags((string)$query)); if (!$query) return ''; $query = mb_substr($query, 0, 90); if (!empty($this->settings['pexels_api_key'])) { $url = add_query_arg(array('query' => $query, 'per_page' => 1, 'orientation' => 'landscape'), 'https://api.pexels.com/v1/search'); $response = wp_safe_remote_get($url, array('timeout' => 15, 'headers' => array('Authorization' => $this->settings['pexels_api_key']))); if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) < 300) { $data = json_decode(wp_remote_retrieve_body($response), true); $photo = $data['photos'][0]['src']['large'] ?? ''; if ($photo) return esc_url_raw($photo); } } if (!empty($this->settings['pixabay_api_key'])) { $url = add_query_arg(array('key' => $this->settings['pixabay_api_key'], 'q' => rawurlencode($query), 'image_type' => 'photo', 'orientation' => 'horizontal', 'safesearch' => 'true', 'per_page' => 3), 'https://pixabay.com/api/'); $response = wp_safe_remote_get($url, array('timeout' => 15)); if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) < 300) { $data = json_decode(wp_remote_retrieve_body($response), true); $photo = $data['hits'][0]['largeImageURL'] ?? ($data['hits'][0]['webformatURL'] ?? ''); if ($photo) return esc_url_raw($photo); } } if (!empty($this->settings['hf_api_key'])) { $ai_photo = $this->generate_ai_image($query); if ($ai_photo) return $ai_photo; } return ''; } /** * AI-generated featured image, tried only after both stock-photo * sources (Pexels, Pixabay) come up empty -- most editorial queries * DO have a real stock match, so this only fires for genuinely * niche/specific topics a stock library has no photo of. * * Uses Hugging Face's Inference Providers routing (HF_API_KEY, the * same key already used elsewhere in this org's infra) to fal.ai's * FLUX.1-schnell, confirmed live 2026-09-07 against the exact * endpoint shape HF's router actually expects for this provider -- * NOT the deprecated api-inference.huggingface.co host, and NOT the * generic OpenAI-style /v1/images/generations path (fal.ai's own * native request/response shape, reached via router.huggingface.co * with the provider name doubled in the path: POST * /fal-ai/fal-ai/flux/schnell -- confirmed by tracing the one * already-proven-working caller in this org's codebase, tamgpt6's * own image_handler.py, rather than guessed from the (misleading) * generic Inference Providers docs). * * Rejects any result fal.ai itself flags as an NSFW concept rather * than publishing it -- this runs unattended on public news/business * sites, so a false-negative here (skip a safe image) is the far * cheaper mistake than a false-positive (publish an unsafe one). */ private function generate_ai_image($query) { $prompt = 'Professional editorial photograph illustrating: '.$query.'. Realistic photography style, high quality, suitable as a news article featured image, no text or watermarks in the image.'; $response = wp_safe_remote_post('https://router.huggingface.co/fal-ai/fal-ai/flux/schnell', array( 'timeout' => 45, 'headers' => array( 'Authorization' => 'Bearer '.$this->settings['hf_api_key'], 'Content-Type' => 'application/json', ), 'body' => wp_json_encode(array( 'prompt' => mb_substr($prompt, 0, 900), 'image_size' => array('width' => 1024, 'height' => 1024), )), )); if (is_wp_error($response)) { $this->log('warning', 'AI image generation failed: '.$response->get_error_message(), $query); return ''; } if (wp_remote_retrieve_response_code($response) >= 300) { $this->log('warning', 'AI image generation returned HTTP '.wp_remote_retrieve_response_code($response), $query); return ''; } $data = json_decode(wp_remote_retrieve_body($response), true); if (!empty($data['has_nsfw_concepts'][0])) { $this->log('warning', 'AI image generation result flagged as NSFW, discarded.', $query); return ''; } $photo = $data['images'][0]['url'] ?? ''; return $photo ? esc_url_raw($photo) : ''; } private function fallback_article($item) { return array('title' => $item['title'], 'excerpt' => wp_trim_words($item['excerpt'] ?: $item['content'], 35), 'introduction' => wp_trim_words($item['content'], 90), 'sections' => array(array('heading' => 'What happened', 'paragraphs' => array(wp_trim_words($item['content'], 220)))), 'key_takeaways' => array(), 'conclusion' => '', 'tags' => array()); } private function format_article($article, $item) { $html = '
'; $html .= '

'.esc_html($article['introduction'] ?? $article['excerpt']).'

'; if (!empty($article['key_takeaways'])) { $html .= ''; } if (!empty($item['video']) && !empty($this->settings['embed_videos'])) { // A bare URL alone on its own line is WordPress's own oEmbed // convention: core's autoembed content filter (regex, line-based) // expands it into a responsive player on render. Wrapping it in // a tag would break that line-anchored match, and this survives // wp_kses_post() below with no iframe allowlisting needed. $html .= "\n".esc_url($this->normalize_video_url($item['video']))."\n"; } foreach ((array)$article['sections'] as $section) { if (empty($section['heading'])) continue; $html .= '

'.esc_html($section['heading']).'

'; foreach ((array)($section['paragraphs'] ?? array()) as $paragraph) $html .= '

'.esc_html($paragraph).'

'; $html .= '
'; } if (!empty($article['conclusion'])) $html .= '

What this means

'.esc_html($article['conclusion']).'

'; if (!empty($this->settings['source_attribution'])) $html .= '

Source: '.esc_html($item['title']).' via '.esc_html($item['source']).'.

'; if (!empty($this->settings['add_disclosure'])) $html .= '

This article was curated with AI assistance and reviewed according to Tamfis editorial settings.

'; $html .= '
'; return wp_kses_post($html); } private function insert_inline_image($content, $attachment_id, $title) { $image = wp_get_attachment_image($attachment_id, 'large', false, array('class'=>'tab-inline-image','loading'=>'lazy','decoding'=>'async','alt'=>sanitize_text_field($title))); if (!$image) return $content; $figure = '
'.$image.'
'.esc_html($title).'
'; $position = strpos($content, '

'); return $position === false ? $figure.$content : substr_replace($content, '

'.$figure, $position, 4); } private function attach_image($post_id, $url, $title) { if (!wp_http_validate_url($url)) return new WP_Error('tab_image_url', 'Source image URL is invalid.'); require_once ABSPATH.'wp-admin/includes/media.php'; require_once ABSPATH.'wp-admin/includes/file.php'; require_once ABSPATH.'wp-admin/includes/image.php'; $attachment_id = media_sideload_image($url, $post_id, $title, 'id'); if (!is_wp_error($attachment_id)) { set_post_thumbnail($post_id, $attachment_id); update_post_meta($attachment_id, '_wp_attachment_image_alt', sanitize_text_field($title)); return $attachment_id; } $this->log('warning', 'Image import failed: '.$attachment_id->get_error_message(), $url); return $attachment_id; } public function log($level, $message, $source = '') { $logs = get_option('tab_logs', array()); array_unshift($logs, array('time' => current_time('mysql'), 'level' => sanitize_key($level), 'message' => sanitize_text_field($message), 'source' => esc_url_raw($source))); $keep_days = max(1, (int)(tab_settings()['log_keep_days'] ?? 30)); $cutoff = current_time('timestamp') - $keep_days * DAY_IN_SECONDS; $logs = array_values(array_filter($logs, function($row) use ($cutoff) { return strtotime((string)($row['time'] ?? '')) >= $cutoff; })); update_option('tab_logs', array_slice($logs, 0, 200), false); } }