settings = $settings; $this->api = $api; } // ----------------------------------------------------------------------- // Lifecycle (static — called via activation / deactivation hooks) // ----------------------------------------------------------------------- public static function activate(): void { self::register_schedules(); self::ensure_scheduled(); } public static function deactivate(): void { while ( $ts = wp_next_scheduled( self::CRON_HOOK ) ) { wp_unschedule_event( $ts, self::CRON_HOOK ); } delete_transient( self::LOCK_TRANSIENT ); } /** * Register the plugin's custom WP-Cron recurrence. */ public static function register_schedules(): void { if ( ! has_filter( 'cron_schedules', array( __CLASS__, 'add_cron_schedules' ) ) ) { add_filter( 'cron_schedules', array( __CLASS__, 'add_cron_schedules' ) ); } } /** * Add a six-hour recurrence to WordPress schedules. * * @param array $schedules Existing schedules. * @return array */ public static function add_cron_schedules( array $schedules ): array { if ( ! isset( $schedules[ self::DEFAULT_RECURRENCE ] ) ) { $schedules[ self::DEFAULT_RECURRENCE ] = array( 'interval' => 6 * HOUR_IN_SECONDS, 'display' => __( 'Every six hours', 'ranktim-article-sync' ), ); } return $schedules; } /** * Return the configured recurrence, falling back safely when invalid. */ public static function get_cron_recurrence(): string { $recurrence = defined( 'RANKTIM_SYNC_RECURRENCE' ) ? (string) RANKTIM_SYNC_RECURRENCE : self::DEFAULT_RECURRENCE; $schedules = wp_get_schedules(); return isset( $schedules[ $recurrence ] ) ? $recurrence : self::DEFAULT_RECURRENCE; } /** * Ensure the scheduled event exists and migrate older hourly installs. */ public static function ensure_scheduled(): void { $recurrence = self::get_cron_recurrence(); $next = wp_next_scheduled( self::CRON_HOOK ); if ( $next ) { $current = wp_get_schedule( self::CRON_HOOK ); if ( $current === $recurrence ) { return; } self::deactivate(); } if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_event( time() + ( 5 * MINUTE_IN_SECONDS ), $recurrence, self::CRON_HOOK ); } } /** * Attach a lazy cron runner so normal page loads do not instantiate settings/API. */ public static function register_runner(): void { add_action( self::CRON_HOOK, array( __CLASS__, 'run_scheduled_sync' ) ); } /** * Build dependencies only when WP-Cron actually executes this hook. */ public static function run_scheduled_sync(): void { $encryption = new Ranktim_Encryption(); $settings = new Ranktim_Settings( $encryption ); $api = new Ranktim_Api_Client(); ( new self( $settings, $api ) )->run_sync(); } // ----------------------------------------------------------------------- // Registration // ----------------------------------------------------------------------- /** Attach the cron callback to WordPress. */ public function register(): void { add_action( self::CRON_HOOK, array( $this, 'run_sync' ) ); } /** * Return how many previous days should be re-checked on each sync. * Capped to keep the API workload predictable. */ public static function get_sync_lookback_days(): int { $days = defined( 'RANKTIM_SYNC_LOOKBACK_DAYS' ) ? (int) RANKTIM_SYNC_LOOKBACK_DAYS : 7; return max( 0, min( 30, $days ) ); } /** * Return the list of dates that belong to the current sync window. * Includes today and the configured number of previous days. * * @return string[] */ public static function get_sync_dates( bool $oldest_first = true ): array { $dates = array(); $lookback_days = self::get_sync_lookback_days(); for ( $offset = 0; $offset <= $lookback_days; $offset++ ) { $dates[] = gmdate( 'Y-m-d', time() - ( $offset * 86400 ) ); } if ( $oldest_first ) { $dates = array_reverse( $dates ); } return $dates; } /** * Fetch queue items for the current sync window, annotated with their date. * * @param string $key Plaintext integration key. * @return array{items: array, errors: array, lookback_days: int} */ public function get_queue_window( string $key, bool $oldest_first = false ): array { $cache_key = self::get_queue_window_cache_key( $key, $oldest_first ); $cached = get_transient( $cache_key ); if ( is_array( $cached ) && isset( $cached['items'], $cached['errors'], $cached['lookback_days'] ) ) { return $cached; } $items = array(); $errors = array(); foreach ( self::get_sync_dates( $oldest_first ) as $date ) { $date_items = $this->fetch_items_for_date( $date, $key ); if ( is_wp_error( $date_items ) ) { $errors[ $date ] = $date_items->get_error_message(); continue; } foreach ( $date_items as $item ) { $item['scheduled_date'] = $date; $items[] = $item; } } $data = array( 'items' => $items, 'errors' => $errors, 'lookback_days' => self::get_sync_lookback_days(), ); set_transient( $cache_key, $data, 5 * MINUTE_IN_SECONDS ); return $data; } /** * Clear cached admin queue data for an integration key. */ public static function clear_queue_window_cache( string $key ): void { delete_transient( self::get_queue_window_cache_key( $key, true ) ); delete_transient( self::get_queue_window_cache_key( $key, false ) ); } /** * Build a cache key without exposing the integration key. */ private static function get_queue_window_cache_key( string $key, bool $oldest_first ): string { return 'ranktim_queue_' . md5( $key . '|' . ( $oldest_first ? 'oldest' : 'newest' ) . '|' . implode( ',', self::get_sync_dates( $oldest_first ) ) ); } // ----------------------------------------------------------------------- // Sync workflow // ----------------------------------------------------------------------- /** Main synchronisation entry-point for scheduled and manual syncs. */ public function run_sync( bool $force_full_window = false ): void { if ( ! $this->acquire_lock() ) { Ranktim_Logger::log( '[Ranktim] Sync skipped: another sync is already running.' ); return; } try { $this->run_sync_unlocked( $force_full_window ); } finally { delete_transient( self::LOCK_TRANSIENT ); } } /** * Run sync after the overlap lock has been acquired. */ private function run_sync_unlocked( bool $force_full_window = false ): void { $settings = $this->settings->get(); $key = $settings['integration_key']; if ( empty( $key ) ) { return; } $full_window = $force_full_window || $this->is_catchup_due(); $dates = $full_window ? self::get_sync_dates( true ) : array( gmdate( 'Y-m-d' ) ); $window_start = $dates[0] ?? gmdate( 'Y-m-d' ); $window_end = $dates[ count( $dates ) - 1 ] ?? $window_start; $total_found = 0; $total_new = 0; Ranktim_Logger::log( '[Ranktim] Sync started for window ' . $window_start . ' → ' . $window_end ); foreach ( $dates as $date ) { $items = $this->fetch_items_for_date( $date, $key ); if ( is_wp_error( $items ) ) { Ranktim_Logger::log( '[Ranktim] Content list error for ' . $date . ': ' . $items->get_error_message() ); continue; } if ( empty( $items ) ) { continue; } $total_found += count( $items ); $total_new += $this->sync_items_for_date( $items, $date, $key, $settings ); } if ( $full_window ) { update_option( self::LAST_CATCHUP_OPTION, time(), false ); } self::clear_queue_window_cache( $key ); Ranktim_Logger::log( '[Ranktim] Sync complete for window ' . $window_start . ' → ' . $window_end . '. Imported ' . $total_new . ' new item(s) from ' . $total_found . ' queued item(s).' ); } // ----------------------------------------------------------------------- // Private helpers // ----------------------------------------------------------------------- /** * Prevent overlapping cron/manual sync runs on busy sites. */ private function acquire_lock(): bool { if ( get_transient( self::LOCK_TRANSIENT ) ) { return false; } set_transient( self::LOCK_TRANSIENT, 1, max( 5 * MINUTE_IN_SECONDS, RANKTIM_API_TIMEOUT * 4 ) ); return true; } /** * Return whether the scheduled run should include the full backlog window. */ private function is_catchup_due(): bool { $last_run = (int) get_option( self::LAST_CATCHUP_OPTION, 0 ); $interval = defined( 'RANKTIM_CATCHUP_INTERVAL' ) ? (int) RANKTIM_CATCHUP_INTERVAL : DAY_IN_SECONDS; $interval = max( HOUR_IN_SECONDS, $interval ); return 0 === $last_run || ( time() - $last_run ) >= $interval; } /** * Query whether a WP post already exists for a Ranktim item ID. * Uses post meta `_ranktim_item_id` as the unique key. */ private function post_exists( string $item_id ): bool { $q = new WP_Query( array( 'post_type' => 'post', 'post_status' => array( 'publish', 'draft', 'pending', 'future', 'trash' ), 'meta_key' => '_ranktim_item_id', 'meta_value' => $item_id, 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, ) ); return $q->have_posts(); } /** * Fetch queue items for a single date. * * @param string $date YYYY-MM-DD. * @param string $key Plaintext integration key. * @return array|WP_Error */ private function fetch_items_for_date( string $date, string $key ) { $result = $this->api->get( '/api/integrations/content?date=' . rawurlencode( $date ), $key ); if ( is_wp_error( $result ) ) { return $result; } return isset( $result['items'] ) && is_array( $result['items'] ) ? $result['items'] : array(); } /** * Process all queue items for a given date. * * @param array $items Queue items for the selected date. * @param string $date YYYY-MM-DD. * @param string $key Plaintext integration key. * @param array $settings Full settings array from Ranktim_Settings::get(). * @return int Number of newly imported posts. */ private function sync_items_for_date( array $items, string $date, string $key, array $settings ): int { $imported = 0; foreach ( $items as $item ) { // API uses "id" (no underscore prefix). $item_id = isset( $item['id'] ) ? sanitize_text_field( $item['id'] ) : ''; if ( empty( $item_id ) ) { continue; } // Skip items not yet ready. $status = isset( $item['status'] ) ? $item['status'] : ''; if ( 'ready' !== $status ) { Ranktim_Logger::log( '[Ranktim] Skipping item ' . $item_id . ' from ' . $date . ' — status is "' . $status . '" (not ready).' ); continue; } // If a post already exists locally, retry ACK so the remote queue can heal. if ( $this->post_exists( $item_id ) ) { $this->acknowledge_existing_item( $item_id, $key, $date ); continue; } $article = $this->api->get( '/api/integrations/articles/' . rawurlencode( $item_id ), $key ); if ( is_wp_error( $article ) ) { Ranktim_Logger::log( '[Ranktim] Failed to fetch article ' . $item_id . ' from ' . $date . ': ' . $article->get_error_message() ); continue; } $post_id = $this->insert_post( $article, $item, $item_id, $settings ); if ( is_wp_error( $post_id ) ) { Ranktim_Logger::log( '[Ranktim] wp_insert_post failed for item ' . $item_id . ' from ' . $date . ': ' . $post_id->get_error_message() ); continue; } $imported++; Ranktim_Logger::log( '[Ranktim] Created post ID ' . $post_id . ' for item ' . $item_id . ' from ' . $date ); $this->acknowledge_item( $item_id, $key, $date ); } return $imported; } /** * Acknowledge an item after it has been imported locally. */ private function acknowledge_item( string $item_id, string $key, string $date ): bool { $ack = $this->api->post( '/api/integrations/ack/' . rawurlencode( $item_id ), $key ); if ( is_wp_error( $ack ) ) { Ranktim_Logger::log( '[Ranktim] ACK failed for item ' . $item_id . ' from ' . $date . ': ' . $ack->get_error_message() ); return false; } Ranktim_Logger::log( '[Ranktim] ACK OK for item ' . $item_id . ' from ' . $date ); return true; } /** * Retry ACK for already imported items, but avoid repeating successful ACKs too often. */ private function acknowledge_existing_item( string $item_id, string $key, string $date ): void { $transient_key = 'ranktim_ack_' . md5( $item_id ); if ( get_transient( $transient_key ) ) { return; } if ( $this->acknowledge_item( $item_id, $key, $date ) ) { set_transient( $transient_key, 1, 12 * HOUR_IN_SECONDS ); } } /** * Create a WordPress post from an article API payload. * * Maps the full generatedPayload to WP post fields and meta: * - title → post_title * - content (Markdown) → post_content (converted to HTML) * - generatedPayload.slug → post_name * - generatedPayload.metaDescription → post_excerpt + SEO meta * - generatedPayload.metaTitle → SEO meta title (Yoast + RankMath) * - generatedPayload.primaryKeyword → Yoast focus-keyword + RankMath focus kw * - generatedPayload.secondaryKeywords, cta, internalLinkSuggestions, * imageSuggestions → stored as _ranktim_* post meta * - imageUrl → sideloaded as featured image (if present) * * @param array $article Full article payload from /api/integrations/articles/{id}. * @param array $item Item payload from /api/integrations/content?date=YYYY-MM-DD. * @param string $item_id Ranktim item ID. * @param array $settings Full settings array from Ranktim_Settings::get(). * @return int|WP_Error New post ID or WP_Error. */ private function insert_post( array $article, array $item, string $item_id, array $settings ) { // ---- Settings ---- $mode = $settings['post_mode'] ?? 'publish'; $default_category_id = isset( $settings['default_category_id'] ) ? (int) $settings['default_category_id'] : 0; $default_author_id = isset( $settings['default_author_id'] ) ? (int) $settings['default_author_id'] : 0; $default_tag_ids = isset( $settings['default_tag_ids'] ) && is_array( $settings['default_tag_ids'] ) ? $settings['default_tag_ids'] : array(); $remove_duplicate_content = ! empty( $settings['remove_duplicate_content'] ); $payload = isset( $article['generatedPayload'] ) && is_array( $article['generatedPayload'] ) ? $article['generatedPayload'] : array(); // ---- Core fields ---- $title = ! empty( $article['title'] ) ? sanitize_text_field( $article['title'] ) : __( '(No title)', 'ranktim-article-sync' ); // Prefer generatedPayload.articleMarkdown; fall back to top-level content. $markdown = ''; if ( ! empty( $payload['articleMarkdown'] ) ) { $markdown = $payload['articleMarkdown']; } elseif ( ! empty( $article['content'] ) ) { $markdown = $article['content']; } $html_content = wp_kses_post( $this->markdown_to_html( $markdown ) ); if ( $remove_duplicate_content ) { $html_content = $this->strip_duplicate_content( $html_content ); } // Slug from generatedPayload; fall back to the API's top-level slug. $post_name = ! empty( $payload['slug'] ) ? sanitize_title( $payload['slug'] ) : ( ! empty( $article['slug'] ) ? sanitize_title( $article['slug'] ) : '' ); // Excerpt = metaDescription (ideal length for WP excerpts too). $excerpt = ! empty( $payload['metaDescription'] ) ? sanitize_textarea_field( $payload['metaDescription'] ) : ( ! empty( $article['metaDescription'] ) ? sanitize_textarea_field( $article['metaDescription'] ) : '' ); // Determine post status: local plugin setting takes precedence; // fall back to the API's postMode field if local setting is absent. $api_post_mode = ! empty( $article['postMode'] ) ? $article['postMode'] : 'draft'; $effective_mode = ! empty( $mode ) ? $mode : $api_post_mode; $post_status = ( 'draft' === $effective_mode ) ? 'draft' : 'publish'; // ---- SEO meta — supports Yoast SEO and RankMath ---- $meta_title = ! empty( $payload['metaTitle'] ) ? sanitize_text_field( $payload['metaTitle'] ) : ( ! empty( $article['metaTitle'] ) ? sanitize_text_field( $article['metaTitle'] ) : '' ); $meta_description = ! empty( $payload['metaDescription'] ) ? sanitize_textarea_field( $payload['metaDescription'] ) : ( ! empty( $article['metaDescription'] ) ? sanitize_textarea_field( $article['metaDescription'] ) : '' ); $primary_keyword = ! empty( $payload['primaryKeyword'] ) ? sanitize_text_field( $payload['primaryKeyword'] ) : ( ! empty( $article['primaryKeyword'] ) ? sanitize_text_field( $article['primaryKeyword'] ) : '' ); $featured_image_url = $this->pick_image_field( $article, $item, 'imageUrl' ); $featured_image_alt = $this->pick_image_field( $article, $item, 'imageAlt' ); // ---- Build meta_input ---- $meta_input = array( // Ranktim tracking. '_ranktim_item_id' => $item_id, '_ranktim_synced_at' => time(), ); if ( $meta_title ) { // Yoast SEO. $meta_input['_yoast_wpseo_title'] = $meta_title; // RankMath. $meta_input['rank_math_title'] = $meta_title; // Generic fallback. $meta_input['_ranktim_meta_title'] = $meta_title; } if ( $meta_description ) { // Yoast SEO. $meta_input['_yoast_wpseo_metadesc'] = $meta_description; // RankMath. $meta_input['rank_math_description'] = $meta_description; // Generic fallback. $meta_input['_ranktim_meta_description'] = $meta_description; } if ( $primary_keyword ) { // Yoast SEO. $meta_input['_yoast_wpseo_focuskw'] = $primary_keyword; // RankMath. $meta_input['rank_math_focus_keyword'] = $primary_keyword; // Generic fallback. $meta_input['_ranktim_primary_keyword'] = $primary_keyword; } // Secondary keywords (serialised array). if ( ! empty( $payload['secondaryKeywords'] ) && is_array( $payload['secondaryKeywords'] ) ) { $meta_input['_ranktim_secondary_keywords'] = array_map( 'sanitize_text_field', $payload['secondaryKeywords'] ); } // CTA text. if ( ! empty( $payload['cta'] ) ) { $meta_input['_ranktim_cta'] = sanitize_textarea_field( $payload['cta'] ); } // Internal link suggestions (array of strings). if ( ! empty( $payload['internalLinkSuggestions'] ) && is_array( $payload['internalLinkSuggestions'] ) ) { $meta_input['_ranktim_internal_link_suggestions'] = array_map( 'sanitize_text_field', $payload['internalLinkSuggestions'] ); } // Image suggestions (array of {altText, description} objects). if ( ! empty( $payload['imageSuggestions'] ) && is_array( $payload['imageSuggestions'] ) ) { $meta_input['_ranktim_image_suggestions'] = $payload['imageSuggestions']; } // Article featured image meta (from content plan). if ( ! empty( $featured_image_url ) ) { $meta_input['_ranktim_image_url'] = esc_url_raw( $featured_image_url ); } if ( ! empty( $featured_image_alt ) ) { $meta_input['_ranktim_image_alt'] = sanitize_text_field( $featured_image_alt ); } // Outline. if ( ! empty( $payload['outline'] ) && is_array( $payload['outline'] ) ) { $meta_input['_ranktim_outline'] = array_map( 'sanitize_text_field', $payload['outline'] ); } // ---- Create the post ---- $post_arr = array( 'post_title' => $title, 'post_content' => $html_content, 'post_excerpt' => $excerpt, 'post_status' => $post_status, 'post_author' => $default_author_id > 0 ? $default_author_id : 1, 'meta_input' => $meta_input, ); if ( $post_name ) { $post_arr['post_name'] = $post_name; } $post_id = wp_insert_post( $post_arr, true ); if ( is_wp_error( $post_id ) ) { return $post_id; } // ---- Default category ---- if ( $default_category_id > 0 ) { wp_set_post_categories( $post_id, array( $default_category_id ) ); } // ---- Default tags ---- if ( ! empty( $default_tag_ids ) ) { wp_set_post_tags( $post_id, $default_tag_ids ); } // ---- Featured image — sideload imageUrl if present (from content plan) ---- if ( ! empty( $featured_image_url ) ) { $image_alt = ! empty( $featured_image_alt ) ? sanitize_text_field( $featured_image_alt ) : $title; $this->set_featured_image( $post_id, $featured_image_url, $image_alt ); } $this->save_json_ld_schema( $post_id, $article, $item, $payload, $title, $meta_description, $primary_keyword, $featured_image_url ); return $post_id; } /** * Build and store Article/BlogPosting JSON-LD for the imported post. * * The API can provide jsonLd directly. When it does, WordPress enriches it * with the final permalink, author, publisher, dates, and image data. */ private function save_json_ld_schema( int $post_id, array $article, array $item, array $payload, string $title, string $meta_description, string $primary_keyword, string $featured_image_url ): void { $api_schema = $this->normalize_json_ld_schema( $article['jsonLd'] ?? ( $payload['jsonLd'] ?? null ) ); $fallback = $this->build_fallback_json_ld_schema( $post_id, $article, $item, $payload, $title, $meta_description, $primary_keyword, $featured_image_url ); $schema = ! empty( $api_schema ) ? $api_schema : $fallback; if ( empty( $schema ) ) { return; } if ( ! isset( $schema['@context'] ) ) { $schema['@context'] = 'https://schema.org'; } if ( ! isset( $schema['@type'] ) && isset( $fallback['@type'] ) ) { $schema['@type'] = $fallback['@type']; } foreach ( array( 'headline', 'description', 'image', 'author', 'publisher', 'datePublished', 'dateModified', 'keywords' ) as $key ) { if ( empty( $schema[ $key ] ) && ! empty( $fallback[ $key ] ) ) { $schema[ $key ] = $fallback[ $key ]; } } if ( ! empty( $fallback['url'] ) ) { $schema['url'] = $fallback['url']; $schema['mainEntityOfPage'] = $fallback['mainEntityOfPage']; } update_post_meta( $post_id, Ranktim_Schema::META_KEY, $this->sanitize_json_ld_schema_value( $schema ) ); } /** * Build a complete fallback schema from WordPress and API fields. */ private function build_fallback_json_ld_schema( int $post_id, array $article, array $item, array $payload, string $title, string $meta_description, string $primary_keyword, string $featured_image_url ): array { $permalink = get_permalink( $post_id ); $images = $this->collect_json_ld_images( $post_id, $article, $payload, $featured_image_url ); $keywords = $this->collect_json_ld_keywords( $primary_keyword, $article, $item, $payload ); $author_id = (int) get_post_field( 'post_author', $post_id ); $author = $author_id > 0 ? get_the_author_meta( 'display_name', $author_id ) : ''; $schema = array( '@context' => 'https://schema.org', '@type' => $this->get_json_ld_article_type( $article, $payload ), 'headline' => $title, 'author' => array( '@type' => 'Person', 'name' => $author ? $author : get_bloginfo( 'name' ), ), 'publisher' => array( '@type' => 'Organization', 'name' => get_bloginfo( 'name' ), 'url' => home_url( '/' ), ), 'datePublished' => $this->get_json_ld_published_date( $post_id, $article, $item ), 'dateModified' => get_post_modified_time( 'c', false, $post_id ), 'url' => $permalink, 'mainEntityOfPage' => array( '@type' => 'WebPage', '@id' => $permalink, ), ); if ( '' !== $meta_description ) { $schema['description'] = $meta_description; } if ( ! empty( $images ) ) { $schema['image'] = $images; } if ( ! empty( $keywords ) ) { $schema['keywords'] = $keywords; } return $schema; } /** * Decode API-provided JSON-LD if present. */ private function normalize_json_ld_schema( $value ): array { if ( is_string( $value ) ) { $value = json_decode( $value, true ); } if ( ! is_array( $value ) || empty( $value ) ) { return array(); } return $this->sanitize_json_ld_schema_value( $value ); } /** * Recursively sanitize JSON-LD while preserving schema.org keys such as @type. */ private function sanitize_json_ld_schema_value( $value ) { if ( is_array( $value ) ) { $clean = array(); foreach ( $value as $key => $item ) { $clean_key = is_int( $key ) ? $key : preg_replace( '/[^A-Za-z0-9_:@\.-]/', '', (string) $key ); if ( '' === $clean_key ) { continue; } $clean[ $clean_key ] = $this->sanitize_json_ld_schema_value( $item ); } return $clean; } if ( is_string( $value ) ) { return trim( wp_strip_all_tags( $value ) ); } if ( is_int( $value ) || is_float( $value ) || is_bool( $value ) || null === $value ) { return $value; } return ''; } /** * Determine Article vs BlogPosting from API metadata. */ private function get_json_ld_article_type( array $article, array $payload ): string { $type = ''; if ( ! empty( $payload['articleType'] ) && is_string( $payload['articleType'] ) ) { $type = $payload['articleType']; } elseif ( ! empty( $article['contentType'] ) && is_string( $article['contentType'] ) ) { $type = $article['contentType']; } return preg_match( '/blog/i', $type ) ? 'BlogPosting' : 'Article'; } /** * Prefer the content plan scheduled date, then fall back to WP post date. */ private function get_json_ld_published_date( int $post_id, array $article, array $item ): string { $date = $article['scheduledDate'] ?? ( $item['scheduled_date'] ?? ( $item['scheduledDate'] ?? '' ) ); $iso = $this->date_to_iso( is_string( $date ) ? $date : '' ); return $iso ? $iso : get_post_time( 'c', false, $post_id ); } /** * Convert API date strings to an ISO-compatible schema date. */ private function date_to_iso( string $date ): string { $date = trim( $date ); if ( '' === $date ) { return ''; } if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) { return $date; } $timestamp = strtotime( $date ); return $timestamp ? gmdate( 'c', $timestamp ) : ''; } /** * Collect article image URLs from WordPress, API top-level fields, and payload. */ private function collect_json_ld_images( int $post_id, array $article, array $payload, string $featured_image_url ): array { $images = array(); $this->add_json_ld_image_url( $images, get_the_post_thumbnail_url( $post_id, 'full' ) ); $this->add_json_ld_image_url( $images, $featured_image_url ); $this->add_json_ld_image_url( $images, $article['imageUrl'] ?? '' ); $this->add_json_ld_image_url( $images, $article['inlineImageUrl'] ?? '' ); if ( ! empty( $article['images'] ) && is_array( $article['images'] ) ) { foreach ( $article['images'] as $image ) { if ( is_string( $image ) ) { $this->add_json_ld_image_url( $images, $image ); } elseif ( is_array( $image ) ) { $this->add_json_ld_image_url( $images, $image['url'] ?? ( $image['imageUrl'] ?? ( $image['src'] ?? '' ) ) ); } } } if ( ! empty( $payload['generatedImages'] ) && is_array( $payload['generatedImages'] ) ) { foreach ( array( 'hero', 'inline' ) as $placement ) { if ( ! empty( $payload['generatedImages'][ $placement ] ) && is_array( $payload['generatedImages'][ $placement ] ) ) { $this->add_json_ld_image_url( $images, $payload['generatedImages'][ $placement ]['url'] ?? '' ); } } } return array_values( array_unique( $images ) ); } /** * Add a safe HTTP(S) image URL to a schema image list. */ private function add_json_ld_image_url( array &$images, $url ): void { if ( ! is_string( $url ) || '' === trim( $url ) ) { return; } $url = esc_url_raw( trim( $url ) ); $scheme = wp_parse_url( $url, PHP_URL_SCHEME ); if ( $url && in_array( $scheme, array( 'http', 'https' ), true ) ) { $images[] = $url; } } /** * Collect primary and secondary keywords for schema output. */ private function collect_json_ld_keywords( string $primary_keyword, array $article, array $item, array $payload ): array { $keywords = array(); $sources = array( $primary_keyword, $article['primaryKeyword'] ?? '', $item['primaryKeyword'] ?? '', ); foreach ( $sources as $keyword ) { if ( is_string( $keyword ) && '' !== trim( $keyword ) ) { $keywords[] = sanitize_text_field( $keyword ); } } foreach ( array( $payload['secondaryKeywords'] ?? array(), $article['secondaryKeywords'] ?? array(), $item['secondaryKeywords'] ?? array() ) as $secondary_keywords ) { if ( ! is_array( $secondary_keywords ) ) { continue; } foreach ( $secondary_keywords as $keyword ) { if ( is_string( $keyword ) && '' !== trim( $keyword ) ) { $keywords[] = sanitize_text_field( $keyword ); } } } return array_values( array_unique( $keywords ) ); } /** * Strip the first

and the first from an HTML string. * * Used when "Remove double title and image" is enabled — the theme already * renders the post title and featured image above the content, so keeping * them inside the body would duplicate them visually. * * @param string $html Raw HTML content. * @return string Cleaned HTML. */ private function strip_duplicate_content( string $html ): string { // Remove the first

…

(post title already shown by the theme). $html = preg_replace( '/]*>.*?<\/h1>\s*/is', '', $html, 1 ); // Remove the first tag (featured image already shown by the theme). $html = preg_replace( '/]*\/?>\s*/i', '', $html, 1 ); return $html; } /** * Sideload a remote image URL and attach it as the featured image of a post. * * @param int $post_id Target post ID. * @param string $image_url Remote image URL. * @param string $alt_text Alt / description text for the media library. */ private function set_featured_image( int $post_id, string $image_url, string $alt_text ): void { if ( ! function_exists( 'media_sideload_image' ) ) { require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; } $image_url = esc_url_raw( trim( $image_url ) ); if ( empty( $image_url ) ) { return; } $attachment_id = media_sideload_image( $image_url, $post_id, sanitize_text_field( $alt_text ), 'id' ); // media_sideload_image() fails for extension-less URLs (e.g. Convex storage). if ( is_wp_error( $attachment_id ) ) { $attachment_id = $this->sideload_image_without_extension( $post_id, $image_url, $alt_text ); } if ( is_wp_error( $attachment_id ) ) { Ranktim_Logger::log( '[Ranktim] Failed to sideload featured image for post ' . $post_id . ': ' . $attachment_id->get_error_message() ); return; } set_post_thumbnail( $post_id, $attachment_id ); update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $alt_text ) ); } /** * Sideload helper for image URLs that do not contain a file extension. * * @param int $post_id Target post ID. * @param string $image_url Remote image URL. * @param string $alt_text Alt text for the attachment. * @return int|WP_Error */ private function sideload_image_without_extension( int $post_id, string $image_url, string $alt_text ) { $tmp_file = download_url( $image_url, RANKTIM_API_TIMEOUT ); if ( is_wp_error( $tmp_file ) ) { return $tmp_file; } $mime_type = $this->detect_image_mime_type( $image_url, $tmp_file ); $extension = $this->mime_to_extension( $mime_type ); $path = wp_parse_url( $image_url, PHP_URL_PATH ); $basename = is_string( $path ) ? basename( $path ) : ''; $basename = sanitize_file_name( $basename ); if ( '' === $basename ) { $basename = 'ranktim-image-' . $post_id; } if ( ! preg_match( '/\.[a-z0-9]{2,5}$/i', $basename ) ) { $basename .= '.' . $extension; } $file_array = array( 'name' => $basename, 'tmp_name' => $tmp_file, ); $attachment_id = media_handle_sideload( $file_array, $post_id, sanitize_text_field( $alt_text ) ); if ( is_wp_error( $attachment_id ) && file_exists( $tmp_file ) ) { wp_delete_file( $tmp_file ); } return $attachment_id; } /** * Choose a field from article payload first, then fall back to queue item payload. * * @param array $article Full article payload. * @param array $item Queue item payload. * @param string $key Field key, e.g. imageUrl / imageAlt. * @return string */ private function pick_image_field( array $article, array $item, string $key ): string { if ( ! empty( $article[ $key ] ) && is_string( $article[ $key ] ) ) { return $article[ $key ]; } if ( ! empty( $item[ $key ] ) && is_string( $item[ $key ] ) ) { return $item[ $key ]; } return ''; } /** * Detect remote image MIME type using HTTP headers or file fallback. * * @param string $image_url Remote image URL. * @param string $tmp_file Local downloaded temp file path. * @return string MIME type, defaults to image/jpeg. */ private function detect_image_mime_type( string $image_url, string $tmp_file ): string { $head = wp_remote_head( $image_url, array( 'timeout' => RANKTIM_API_TIMEOUT ) ); if ( ! is_wp_error( $head ) ) { $header = wp_remote_retrieve_header( $head, 'content-type' ); if ( is_string( $header ) && '' !== $header ) { $mime = trim( strtolower( explode( ';', $header )[0] ) ); if ( 0 === strpos( $mime, 'image/' ) ) { return $mime; } } } $ft = wp_check_filetype_and_ext( $tmp_file, basename( $tmp_file ) ); if ( ! empty( $ft['type'] ) && 0 === strpos( $ft['type'], 'image/' ) ) { return $ft['type']; } return 'image/jpeg'; } /** * Map MIME types to safe image extensions for WordPress sideload. * * @param string $mime_type Image MIME type. * @return string File extension without dot. */ private function mime_to_extension( string $mime_type ): string { $map = array( 'image/jpeg' => 'jpg', 'image/jpg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', 'image/avif' => 'avif', ); return $map[ strtolower( $mime_type ) ] ?? 'jpg'; } /** * Minimal Markdown → HTML converter that handles the subset used by * the Ranktim article generator: * * # H1 ## H2 ### H3 #### H4 * **bold** *italic* `code` * [text](url) * - unordered list items * 1. ordered list items * Blank-line-separated paragraphs * * Does NOT depend on any external library. * * @param string $markdown Raw Markdown text. * @return string HTML string. */ private function markdown_to_html( string $markdown ): string { if ( '' === trim( $markdown ) ) { return ''; } // Normalise line endings. $md = str_replace( "\r\n", "\n", $markdown ); $md = str_replace( "\r", "\n", $md ); // Split into lines for block-level processing. $lines = explode( "\n", $md ); $output = ''; $i = 0; $total = count( $lines ); while ( $i < $total ) { $line = $lines[ $i ]; // --- Headings --- if ( preg_match( '/^(#{1,6})\s+(.+)$/', $line, $m ) ) { $level = strlen( $m[1] ); $text = $this->inline_md( $m[2] ); $output .= "{$text}\n"; $i++; continue; } // --- Unordered list block --- if ( preg_match( '/^[-*+]\s+(.+)$/', $line ) ) { $output .= "
    \n"; while ( $i < $total && preg_match( '/^[-*+]\s+(.+)$/', $lines[ $i ], $m ) ) { $output .= '
  • ' . $this->inline_md( $m[1] ) . "
  • \n"; $i++; } $output .= "
\n"; continue; } // --- Ordered list block --- if ( preg_match( '/^\d+\.\s+(.+)$/', $line ) ) { $output .= "
    \n"; while ( $i < $total && preg_match( '/^\d+\.\s+(.+)$/', $lines[ $i ], $m ) ) { $output .= '
  1. ' . $this->inline_md( $m[1] ) . "
  2. \n"; $i++; } $output .= "
\n"; continue; } // --- Blank line: paragraph boundary --- if ( '' === trim( $line ) ) { $i++; continue; } // --- Paragraph: collect consecutive non-blank, non-heading, non-list lines --- $para_lines = array(); while ( $i < $total && '' !== trim( $lines[ $i ] ) && ! preg_match( '/^#{1,6}\s/', $lines[ $i ] ) && ! preg_match( '/^[-*+]\s/', $lines[ $i ] ) && ! preg_match( '/^\d+\.\s/', $lines[ $i ] ) ) { $para_lines[] = $lines[ $i ]; $i++; } if ( $para_lines ) { $output .= '

' . $this->inline_md( implode( ' ', $para_lines ) ) . "

\n"; } } return $output; } /** * Process inline Markdown within a single text run. * Handles: ![alt](url), **bold**, *italic*, `code`, [text](url) * * @param string $text Raw inline Markdown. * @return string HTML string (not yet escaped — caller uses wp_kses_post). */ private function inline_md( string $text ): string { // Images: ![alt](url) $text = preg_replace_callback( '/!\[([^\]]*)\]\(([^)]+)\)/', static function ( $m ) { return '' . esc_attr( $m[1] ) . ''; }, $text ); // Links: [text](url) $text = preg_replace_callback( '/\[([^\]]+)\]\(([^)]+)\)/', static function ( $m ) { return '' . esc_html( $m[1] ) . ''; }, $text ); // Bold: **text** or __text__ $text = preg_replace( '/\*\*(.+?)\*\*/', '$1', $text ); $text = preg_replace( '/__(.+?)__/', '$1', $text ); // Italic: *text* or _text_ (single star/underscore) $text = preg_replace( '/\*(.+?)\*/', '$1', $text ); $text = preg_replace( '/_(.+?)_/', '$1', $text ); // Inline code: `code` $text = preg_replace( '/`(.+?)`/', '$1', $text ); return $text; } }