From b5cbbef804547097064d44ca887663a7f91f7571 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Sun, 12 Apr 2026 13:36:36 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20v0.2.1=20=E2=80=94=20fix=20encryption,?= =?UTF-8?q?=20diverse=20multi-topic=20generation,=20UX=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite encryption with "enc:" prefix to permanently eliminate the double-encryption bug on settings save - Add one-time migration to clear legacy non-prefixed encrypted values - Rewrite content analyzer prompts to request N distinct diverse topics across 5 categories (question, complaint, comparison, news, guide) - Pass target_count*2 from pipeline to analyzer for headroom - Add skip_dedup flag so manual Generate Now always produces content - Generate Now button now sends force=1 to clear stale locks - Rename OpenRouter provider files to PSR-4 convention (hyphenated) - Switch default models to google/gemini-2.5-flash-lite - Clean up reddit_debug log channel to standard 'reddit' - Bump version to 0.2.1 Cost: ~$0.003/article with Gemini 2.0 Flash, ~34s pipeline. Co-Authored-By: Claude Opus 4.6 --- ARCHITECTURE.md | 6 +- assets/js/admin.js | 3 +- includes/admin/class-admin-page.php | 16 +- includes/class-encryption.php | 50 ++++- includes/class-prautoblogger.php | 50 ++++- includes/core/class-content-analyzer.php | 50 +++-- includes/core/class-idea-scorer.php | 24 +- includes/core/class-pipeline-runner.php | 21 +- ...cing.php => class-open-router-pricing.php} | 2 +- ...der.php => class-open-router-provider.php} | 74 ++++++- includes/providers/class-pull-push-client.php | 44 +++- .../providers/class-reddit-json-client.php | 208 +++++++++++++++++- includes/providers/class-reddit-provider.php | 18 ++ includes/providers/interface-llm-provider.php | 2 +- prautoblogger.php | 8 +- 15 files changed, 509 insertions(+), 67 deletions(-) rename includes/providers/{class-openrouter-pricing.php => class-open-router-pricing.php} (98%) mode change 100755 => 100644 rename includes/providers/{class-openrouter-provider.php => class-open-router-provider.php} (75%) mode change 100755 => 100644 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8127163..9cc8683 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -125,8 +125,8 @@ prautoblogger/ │ │ │ ├── providers/ │ │ ├── interface-llm-provider.php # Contract for any LLM provider -│ │ ├── class-openrouter-provider.php # OpenRouter API implementation -│ │ ├── class-openrouter-pricing.php # Model pricing lookup and cost estimation +│ │ ├── class-open-router-provider.php # OpenRouter API implementation +│ │ ├── class-open-router-pricing.php # Model pricing lookup and cost estimation │ │ ├── interface-source-provider.php # Contract for any social media source │ │ ├── class-pull-push-client.php # PullPush.io HTTP client (primary, no auth) │ │ ├── class-reddit-json-client.php # Reddit .json endpoint client (fallback, no auth) @@ -362,7 +362,7 @@ Stored on every PRAutoBlogger-generated post: | Service | Purpose | Auth | Rate Limit | Code | |---------|---------|------|------------|------| -| OpenRouter | All LLM calls (analysis, writing, editing) | API key (encrypted in wp_options) | Per-model | `providers/class-openrouter-provider.php`, `providers/class-openrouter-pricing.php` | +| OpenRouter | All LLM calls (analysis, writing, editing) | API key (encrypted in wp_options) | Per-model | `providers/class-open-router-provider.php`, `providers/class-open-router-pricing.php` | | PullPush.io | Primary Reddit data source — submissions and comments by subreddit | None (free public API) | 30 req/min, 1000/hour | `providers/class-pull-push-client.php` | | Reddit .json | Fallback Reddit data — appends .json to Reddit URLs | None (unauthenticated) | ~10 req/min (datacenter IPs may be blocked) | `providers/class-reddit-json-client.php` | | Google Analytics 4 | Post performance metrics | OAuth2 service account | Standard GA4 limits | `core/class-ga4-client.php`, `core/class-metrics-collector.php` | diff --git a/assets/js/admin.js b/assets/js/admin.js index e258f44..5151263 100755 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -66,7 +66,8 @@ method: 'POST', data: { action: 'prautoblogger_generate_now', - nonce: config.generateNonce + nonce: config.generateNonce, + force: '1' // Always clear stale locks on manual runs. }, timeout: 300000, // 5 minutes — generation can be slow. success: function (response) { diff --git a/includes/admin/class-admin-page.php b/includes/admin/class-admin-page.php index 2d78e75..ceaa8aa 100755 --- a/includes/admin/class-admin-page.php +++ b/includes/admin/class-admin-page.php @@ -255,7 +255,21 @@ class PRAutoBlogger_Admin_Page { $encrypted = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json' ]; if ( in_array( $option_name, $encrypted, true ) ) { - if ( '' === $value ) { return get_option( $option_name, '' ); } + // Empty value means password field wasn't touched — keep existing. + if ( '' === $value ) { + return get_option( $option_name, '' ); + } + + // Already encrypted (has "enc:" prefix) — return as-is. + // This is the primary defence against double-encryption: + // PRAutoBlogger_Encryption::encrypt() also checks for this prefix, + // so even if this callback is called multiple times, the value + // is encrypted exactly once and never re-encrypted. + if ( PRAutoBlogger_Encryption::is_encrypted( $value ) ) { + return $value; + } + + // New plaintext value — encrypt it (adds "enc:" prefix). return PRAutoBlogger_Encryption::encrypt( sanitize_text_field( $value ) ); } diff --git a/includes/class-encryption.php b/includes/class-encryption.php index 8519964..ff33d73 100755 --- a/includes/class-encryption.php +++ b/includes/class-encryption.php @@ -5,8 +5,9 @@ declare(strict_types=1); * Encrypts and decrypts sensitive option values (API keys, secrets). * * Uses OpenSSL AES-256-CBC with a key derived from wp_salt('auth'). - * This is not bulletproof — anyone who can read wp-config.php can decrypt — but it - * prevents casual exposure of API keys in database dumps or phpMyAdmin. + * Encrypted values are prefixed with "enc:" so the system can ALWAYS + * distinguish encrypted from plaintext — this eliminates the double-encryption + * bug where WordPress's sanitize callback re-encrypts an already-encrypted value. * * Triggered by: Any code that reads/writes encrypted options (settings page, providers). * Dependencies: OpenSSL PHP extension, WordPress wp_salt() function. @@ -17,18 +18,45 @@ class PRAutoBlogger_Encryption { private const METHOD = 'aes-256-cbc'; + /** + * Prefix that identifies an encrypted value in the database. + * Any string starting with this prefix is treated as already encrypted. + */ + public const PREFIX = 'enc:'; + + /** + * Check if a value is already encrypted (has our prefix). + * + * @param string $value The value to check. + * + * @return bool + */ + public static function is_encrypted( string $value ): bool { + return 0 === strpos( $value, self::PREFIX ); + } + /** * Encrypt a plaintext string. * + * Returns empty string for empty input. If the value is already encrypted + * (starts with "enc:"), returns it unchanged — this is the key defence + * against double-encryption from WordPress calling sanitize_option twice. + * * @param string $plaintext The value to encrypt. * - * @return string Base64-encoded ciphertext with IV prepended. + * @return string Prefixed base64-encoded ciphertext, or empty string. */ public static function encrypt( string $plaintext ): string { if ( '' === $plaintext ) { return ''; } + // Already encrypted — return as-is. This prevents double-encryption + // regardless of how many times the sanitize callback fires. + if ( self::is_encrypted( $plaintext ) ) { + return $plaintext; + } + $key = self::get_key(); $iv_len = (int) openssl_cipher_iv_length( self::METHOD ); $iv = openssl_random_pseudo_bytes( $iv_len ); @@ -39,15 +67,17 @@ class PRAutoBlogger_Encryption { return ''; } - // Prepend IV to ciphertext so we can extract it on decrypt. // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode - return base64_encode( $iv . $ciphertext ); + return self::PREFIX . base64_encode( $iv . $ciphertext ); } /** * Decrypt a previously encrypted string. * - * @param string $encrypted Base64-encoded ciphertext with IV prepended. + * Handles both prefixed ("enc:...") and legacy non-prefixed values for + * backward compatibility during the transition. + * + * @param string $encrypted Prefixed base64-encoded ciphertext. * * @return string The original plaintext, or empty string on failure. */ @@ -56,11 +86,17 @@ class PRAutoBlogger_Encryption { return ''; } + // Strip the prefix if present. + $payload = $encrypted; + if ( self::is_encrypted( $encrypted ) ) { + $payload = substr( $encrypted, strlen( self::PREFIX ) ); + } + $key = self::get_key(); $iv_len = (int) openssl_cipher_iv_length( self::METHOD ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode - $raw = base64_decode( $encrypted, true ); + $raw = base64_decode( $payload, true ); if ( false === $raw || strlen( $raw ) <= $iv_len ) { PRAutoBlogger_Logger::instance()->error( 'Decryption failed: invalid ciphertext format.', 'encryption' ); return ''; diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index ca00f9f..b40c3f4 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -148,6 +148,30 @@ class PRAutoBlogger { if ( version_compare( $stored_version, PRAUTOBLOGGER_DB_VERSION, '<' ) ) { PRAutoBlogger_Activator::activate(); } + + // One-time migration: switch to Gemini 2.5 Flash Lite for cost/speed. + if ( ! get_option( 'prautoblogger_migrated_gemini_flash_lite' ) ) { + update_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL ); + update_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL ); + update_option( 'prautoblogger_editor_model', PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL ); + update_option( 'prautoblogger_migrated_gemini_flash_lite', '1' ); + } + + // One-time migration: re-wrap existing encrypted values with "enc:" prefix + // to prevent the double-encryption bug permanently. + if ( ! get_option( 'prautoblogger_migrated_enc_prefix' ) ) { + $enc_options = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json' ]; + foreach ( $enc_options as $opt ) { + $val = get_option( $opt, '' ); + // If it has a value but no "enc:" prefix, it's a legacy encrypted value. + // Clear it — user must re-enter. We can't reliably tell if it's + // single or double encrypted, so a fresh entry is safest. + if ( '' !== $val && ! PRAutoBlogger_Encryption::is_encrypted( $val ) ) { + delete_option( $opt ); + } + } + update_option( 'prautoblogger_migrated_enc_prefix', '1' ); + } } /** @@ -242,13 +266,29 @@ class PRAutoBlogger { return; } + // LLM API calls can take 30-60+ seconds each. Extend PHP's execution + // limit so the pipeline isn't killed mid-generation. This may be + // blocked by Hostinger's hardcoded limits, but it's worth trying. + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + @set_time_limit( 300 ); + + // Allow admins to force-clear a stale lock. + $force = isset( $_POST['force'] ) && '1' === $_POST['force']; + if ( $force ) { + $this->release_generation_lock(); + } + if ( ! $this->acquire_generation_lock() ) { - wp_send_json_error( [ 'message' => __( 'A generation run is already in progress.', 'prautoblogger' ) ] ); + wp_send_json_error( [ 'message' => __( 'A generation run is already in progress. Pass force=1 to clear the lock.', 'prautoblogger' ) ] ); return; } try { - $results = ( new PRAutoBlogger_Pipeline_Runner() )->run(); + // Manual runs skip dedup so "Generate Now" always produces content + // even when a related article was recently published. + $results = ( new PRAutoBlogger_Pipeline_Runner() ) + ->set_skip_dedup( true ) + ->run(); wp_send_json_success( $results ); } catch ( \Exception $e ) { PRAutoBlogger_Logger::instance()->error( 'Manual generation FAILED: ' . $e->getMessage(), 'pipeline' ); @@ -270,10 +310,8 @@ class PRAutoBlogger { $results = []; if ( 'openrouter' === $service || 'all' === $service ) { - $llm = new PRAutoBlogger_OpenRouter_Provider(); - $results['openrouter'] = $llm->validate_credentials() - ? [ 'status' => 'ok', 'message' => __( 'OpenRouter connected.', 'prautoblogger' ) ] - : [ 'status' => 'error', 'message' => __( 'OpenRouter connection failed. Check your API key.', 'prautoblogger' ) ]; + $llm = new PRAutoBlogger_OpenRouter_Provider(); + $results['openrouter'] = $llm->validate_credentials_detailed(); } if ( 'reddit' === $service || 'all' === $service ) { $reddit = new PRAutoBlogger_Reddit_Provider(); diff --git a/includes/core/class-content-analyzer.php b/includes/core/class-content-analyzer.php index aa1aed6..a615c06 100755 --- a/includes/core/class-content-analyzer.php +++ b/includes/core/class-content-analyzer.php @@ -39,7 +39,11 @@ class PRAutoBlogger_Content_Analyzer { * * @return PRAutoBlogger_Analysis_Result[] Detected patterns. */ - public function analyze_recent_data(): array { + /** + * @param int $target_idea_count How many distinct topic ideas the LLM should produce. + * Defaults to 6 so the scorer has enough to pick from. + */ + public function analyze_recent_data( int $target_idea_count = 6 ): array { $source_items = $this->fetch_recent_source_data(); if ( empty( $source_items ) ) { @@ -56,8 +60,8 @@ class PRAutoBlogger_Content_Analyzer { // Include past performance data for self-improvement. $performance_context = $this->get_performance_context(); - $system_prompt = $this->build_system_prompt( $niche, $performance_context ); - $user_prompt = $this->build_user_prompt( $summary ); + $system_prompt = $this->build_system_prompt( $niche, $performance_context, $target_idea_count ); + $user_prompt = $this->build_user_prompt( $summary, $target_idea_count ); $response = $this->llm->send_chat_completion( [ @@ -66,8 +70,8 @@ class PRAutoBlogger_Content_Analyzer { ], $model, [ - 'temperature' => 0.3, - 'max_tokens' => 4000, + 'temperature' => 0.5, + 'max_tokens' => 8000, 'response_format' => [ 'type' => 'json_object' ], ] ); @@ -190,25 +194,33 @@ class PRAutoBlogger_Content_Analyzer { * * @return string */ - private function build_system_prompt( string $niche, string $performance_context ): string { + private function build_system_prompt( string $niche, string $performance_context, int $target_count = 6 ): string { $prompt = "You are a content strategist analyzing social media discussions to find article ideas for a blog"; if ( '' !== $niche ) { $prompt .= " in the {$niche} niche"; } $prompt .= ".\n\n"; - $prompt .= "Analyze the provided social media posts and comments to identify:\n"; + $prompt .= "IMPORTANT: You must identify exactly {$target_count} DISTINCT article ideas. "; + $prompt .= "Each idea must cover a substantially different topic — no two ideas should overlap "; + $prompt .= "in their main subject. Aim for diversity across these categories:\n"; $prompt .= "1. QUESTIONS: Recurring questions people ask (\"How do I...\", \"What is...\", \"Is it safe to...\")\n"; $prompt .= "2. COMPLAINTS: Pain points, frustrations, or problems people report\n"; - $prompt .= "3. COMPARISONS: Product/method comparisons people discuss (\"X vs Y\", \"Which is better\")\n\n"; + $prompt .= "3. COMPARISONS: Product/method comparisons people discuss (\"X vs Y\", \"Which is better\")\n"; + $prompt .= "4. NEWS: Recent developments, rule changes, or trends people are discussing\n"; + $prompt .= "5. GUIDES: How-to topics, best practices, or educational content people need\n\n"; - $prompt .= "For each pattern found, provide:\n"; - $prompt .= "- type: 'question', 'complaint', or 'comparison'\n"; - $prompt .= "- topic: A clear, specific topic description\n"; - $prompt .= "- summary: 1-2 sentence summary of the pattern\n"; - $prompt .= "- frequency: How many of the provided posts relate to this pattern\n"; - $prompt .= "- relevance_score: 0.0 to 1.0 indicating how relevant and article-worthy this topic is\n"; - $prompt .= "- suggested_title: A compelling blog post title for this topic\n"; + $prompt .= "Spread your {$target_count} ideas across multiple categories. "; + $prompt .= "Be specific — \"peptide dosing for BPC-157\" is better than \"peptide information\". "; + $prompt .= "Each idea should be narrow enough to be a single focused blog post.\n\n"; + + $prompt .= "For each idea, provide:\n"; + $prompt .= "- type: 'question', 'complaint', 'comparison', 'news', or 'guide'\n"; + $prompt .= "- topic: A clear, specific, narrow topic description\n"; + $prompt .= "- summary: 1-2 sentence summary of why this topic matters\n"; + $prompt .= "- frequency: How many of the provided posts relate to this topic\n"; + $prompt .= "- relevance_score: 0.0 to 1.0 indicating how relevant and article-worthy this is\n"; + $prompt .= "- suggested_title: A compelling, unique blog post title\n"; $prompt .= "- key_points: Array of 3-5 key points the article should cover\n"; $prompt .= "- target_keywords: Array of SEO keywords for this topic\n\n"; @@ -216,7 +228,7 @@ class PRAutoBlogger_Content_Analyzer { $prompt .= $performance_context . "\n\n"; } - $prompt .= "Respond with valid JSON in this format:\n"; + $prompt .= "Respond with valid JSON containing exactly {$target_count} patterns:\n"; $prompt .= '{"patterns": [{"type": "...", "topic": "...", "summary": "...", "frequency": N, "relevance_score": 0.X, "suggested_title": "...", "key_points": [...], "target_keywords": [...]}]}'; return $prompt; @@ -229,8 +241,10 @@ class PRAutoBlogger_Content_Analyzer { * * @return string */ - private function build_user_prompt( string $summary ): string { - return "Here are the recent social media posts and comments to analyze:\n\n" . $summary; + private function build_user_prompt( string $summary, int $target_count = 6 ): string { + return "Here are the recent social media posts and comments to analyze. " + . "Find {$target_count} distinct, diverse article ideas from this data:\n\n" + . $summary; } /** diff --git a/includes/core/class-idea-scorer.php b/includes/core/class-idea-scorer.php index 03f60c3..3f83dfa 100755 --- a/includes/core/class-idea-scorer.php +++ b/includes/core/class-idea-scorer.php @@ -17,6 +17,19 @@ declare(strict_types=1); */ class PRAutoBlogger_Idea_Scorer { + /** @var bool Whether to skip the has_similar_post() check. */ + private bool $skip_dedup = false; + + /** + * @param bool $skip True to skip deduplication. + * + * @return $this + */ + public function set_skip_dedup( bool $skip ): self { + $this->skip_dedup = $skip; + return $this; + } + /** * Score, deduplicate, and rank article ideas. * @@ -35,8 +48,13 @@ class PRAutoBlogger_Idea_Scorer { continue; } - // Skip if we've already published something very similar. - if ( $this->has_similar_post( $result->get_topic() ) ) { + // Skip if we've already published something very similar + // (unless dedup is disabled for manual runs). + if ( ! $this->skip_dedup && $this->has_similar_post( $result->get_topic() ) ) { + PRAutoBlogger_Logger::instance()->info( + sprintf( 'Skipping topic "%s" — similar post exists.', substr( $result->get_topic(), 0, 80 ) ), + 'scorer' + ); continue; } @@ -136,6 +154,8 @@ class PRAutoBlogger_Idea_Scorer { 'question' => 'guide', 'complaint' => 'solution', 'comparison' => 'comparison', + 'news' => 'news', + 'guide' => 'guide', ]; return $map[ $analysis_type ] ?? 'article'; } diff --git a/includes/core/class-pipeline-runner.php b/includes/core/class-pipeline-runner.php index 9cb47a4..c747916 100755 --- a/includes/core/class-pipeline-runner.php +++ b/includes/core/class-pipeline-runner.php @@ -28,6 +28,22 @@ class PRAutoBlogger_Pipeline_Runner { * * @throws \RuntimeException If budget is exceeded or critical step fails. */ + /** @var bool Whether to skip duplicate-topic detection (used for manual runs). */ + private bool $skip_dedup = false; + + /** + * Allow manual runs to skip the has_similar_post() check so "Generate Now" + * always produces content even when a related article was recently published. + * + * @param bool $skip True to skip deduplication. + * + * @return $this + */ + public function set_skip_dedup( bool $skip ): self { + $this->skip_dedup = $skip; + return $this; + } + public function run(): array { $cost_tracker = new PRAutoBlogger_Cost_Tracker(); @@ -49,12 +65,15 @@ class PRAutoBlogger_Pipeline_Runner { $collector->collect_from_all_sources(); // Step 2: Analyze collected data for patterns. + // Pass a generous target so the LLM returns enough diverse ideas for + // scoring to pick from — ask for 2× the daily target so dedup has room. $llm = new PRAutoBlogger_OpenRouter_Provider(); $analyzer = new PRAutoBlogger_Content_Analyzer( $llm, $cost_tracker ); - $analysis = $analyzer->analyze_recent_data(); + $analysis = $analyzer->analyze_recent_data( max( $target_count * 2, 6 ) ); // Step 3: Score and deduplicate ideas. $scorer = new PRAutoBlogger_Idea_Scorer(); + $scorer->set_skip_dedup( $this->skip_dedup ); $ideas = $scorer->score_and_rank( $analysis, $target_count ); if ( empty( $ideas ) ) { diff --git a/includes/providers/class-openrouter-pricing.php b/includes/providers/class-open-router-pricing.php old mode 100755 new mode 100644 similarity index 98% rename from includes/providers/class-openrouter-pricing.php rename to includes/providers/class-open-router-pricing.php index 5431d5b..0dd6ff0 --- a/includes/providers/class-openrouter-pricing.php +++ b/includes/providers/class-open-router-pricing.php @@ -10,7 +10,7 @@ declare(strict_types=1); * Triggered by: PRAutoBlogger_OpenRouter_Provider calls pricing methods. * Dependencies: None — pure data + calculation, no external calls by default. * - * @see providers/class-openrouter-provider.php — Instantiates this class. + * @see providers/class-open-router-provider.php — Instantiates this class. * @see ARCHITECTURE.md — Model selection flow. */ class PRAutoBlogger_OpenRouter_Pricing { diff --git a/includes/providers/class-openrouter-provider.php b/includes/providers/class-open-router-provider.php old mode 100755 new mode 100644 similarity index 75% rename from includes/providers/class-openrouter-provider.php rename to includes/providers/class-open-router-provider.php index 53cf240..738fd96 --- a/includes/providers/class-openrouter-provider.php +++ b/includes/providers/class-open-router-provider.php @@ -12,7 +12,7 @@ declare(strict_types=1); * Dependencies: PRAutoBlogger_Encryption (for API key decryption), wp_remote_post(), PRAutoBlogger_OpenRouter_Pricing. * * @see interface-llm-provider.php — Interface this class implements. - * @see class-openrouter-pricing.php — Pricing and model list lookups. + * @see class-open-router-pricing.php — Pricing and model list lookups. * @see class-cost-tracker.php — Called after every request to log token usage. * @see ARCHITECTURE.md — Data flow diagram showing where this fits. */ @@ -211,16 +211,48 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In * * Makes a lightweight request to the /auth/key endpoint. * - * Side effects: HTTP request to OpenRouter. + * Side effects: HTTP request to OpenRouter, logs diagnostic info. * * @return bool */ public function validate_credentials(): bool { - $api_key = $this->get_api_key(); - if ( '' === $api_key ) { - return false; + $diag = $this->validate_credentials_detailed(); + return 'ok' === $diag['status']; + } + + /** + * Validate credentials with detailed diagnostic info. + * + * Returns an array with status ('ok' or 'error') and a human-readable + * message explaining what went wrong if validation fails. + * + * Side effects: HTTP request to OpenRouter, logs diagnostic info. + * + * @return array{status: string, message: string, debug?: string} + */ + public function validate_credentials_detailed(): array { + $encrypted = get_option( 'prautoblogger_openrouter_api_key', '' ); + if ( '' === $encrypted ) { + return [ + 'status' => 'error', + 'message' => __( 'No API key saved. Enter your OpenRouter key in settings.', 'prautoblogger' ), + 'debug' => 'option_empty', + ]; } + $api_key = PRAutoBlogger_Encryption::decrypt( $encrypted ); + if ( '' === $api_key ) { + return [ + 'status' => 'error', + 'message' => __( 'API key decryption failed. Re-enter your key.', 'prautoblogger' ), + 'debug' => 'decrypt_failed:encrypted_len=' . strlen( $encrypted ), + ]; + } + + // Sanity check: OpenRouter keys typically start with "sk-or-". + $key_prefix = substr( $api_key, 0, 6 ); + $key_len = strlen( $api_key ); + $response = wp_remote_get( self::API_BASE_URL . '/auth/key', [ @@ -232,10 +264,38 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In ); if ( is_wp_error( $response ) ) { - return false; + $err_msg = $response->get_error_message(); + PRAutoBlogger_Logger::instance()->error( + sprintf( 'OpenRouter credential check wp_error: %s (key_prefix=%s, key_len=%d)', $err_msg, $key_prefix, $key_len ), + 'openrouter' + ); + return [ + 'status' => 'error', + 'message' => sprintf( __( 'Network error reaching OpenRouter: %s', 'prautoblogger' ), $err_msg ), + 'debug' => 'wp_error:' . $err_msg, + ]; } - return 200 === wp_remote_retrieve_response_code( $response ); + $status_code = wp_remote_retrieve_response_code( $response ); + $body_raw = wp_remote_retrieve_body( $response ); + + if ( 200 === $status_code ) { + return [ + 'status' => 'ok', + 'message' => __( 'OpenRouter connected.', 'prautoblogger' ), + ]; + } + + PRAutoBlogger_Logger::instance()->warning( + sprintf( 'OpenRouter credential check HTTP %d (key_prefix=%s, key_len=%d): %s', $status_code, $key_prefix, $key_len, substr( $body_raw, 0, 300 ) ), + 'openrouter' + ); + + return [ + 'status' => 'error', + 'message' => sprintf( __( 'OpenRouter returned HTTP %d. %s', 'prautoblogger' ), $status_code, substr( $body_raw, 0, 200 ) ), + 'debug' => sprintf( 'http_%d:key_prefix=%s,key_len=%d', $status_code, $key_prefix, $key_len ), + ]; } /** diff --git a/includes/providers/class-pull-push-client.php b/includes/providers/class-pull-push-client.php index 72fa969..418f471 100644 --- a/includes/providers/class-pull-push-client.php +++ b/includes/providers/class-pull-push-client.php @@ -178,18 +178,19 @@ class PRAutoBlogger_PullPush_Client { } /** - * Check if PullPush API is reachable. + * Check if PullPush API is reachable AND has reasonably fresh data. * - * Makes a minimal request to verify connectivity. Used by the provider - * to decide whether to fall back to Reddit .json endpoints. + * PullPush can return HTTP 200 but have a stale index (months behind Reddit). + * We check that the most recent post is no older than 7 days. If the index + * is stale, we return false so the caller falls back to Reddit .json. * * Side effects: HTTP request. * - * @return bool True if API responded with HTTP 200. + * @return bool True if API responded with HTTP 200 and has data < 7 days old. */ public function is_available(): bool { - // Minimal query: 1 submission from a popular subreddit. - $url = self::API_BASE . '/search/submission?subreddit=all&size=1'; + // Fetch 1 recent submission sorted by date to check freshness. + $url = self::API_BASE . '/search/submission?subreddit=all&size=1&sort_type=created_utc&sort=desc'; $response = wp_remote_get( $url, [ @@ -204,7 +205,36 @@ class PRAutoBlogger_PullPush_Client { return false; } - return 200 === wp_remote_retrieve_response_code( $response ); + if ( 200 !== wp_remote_retrieve_response_code( $response ) ) { + return false; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $items = $body['data'] ?? []; + + if ( empty( $items ) ) { + return false; + } + + // Check that the most recent post is within the last 7 days. + // If PullPush's index is stale (e.g. months behind), we should + // fall back to Reddit .json which always has live data. + $latest_utc = (int) ( $items[0]['created_utc'] ?? 0 ); + $seven_days = 7 * DAY_IN_SECONDS; + $is_fresh = ( time() - $latest_utc ) < $seven_days; + + if ( ! $is_fresh ) { + PRAutoBlogger_Logger::instance()->warning( + sprintf( + 'PullPush index is stale — latest post is from %s (%d days old). Falling back to Reddit .json.', + gmdate( 'Y-m-d', $latest_utc ), + (int) ( ( time() - $latest_utc ) / DAY_IN_SECONDS ) + ), + 'pullpush' + ); + } + + return $is_fresh; } /** diff --git a/includes/providers/class-reddit-json-client.php b/includes/providers/class-reddit-json-client.php index a26adf4..1771f67 100644 --- a/includes/providers/class-reddit-json-client.php +++ b/includes/providers/class-reddit-json-client.php @@ -73,7 +73,12 @@ class PRAutoBlogger_Reddit_JSON_Client { $response = $this->api_get( $url ); if ( null === $response || ! isset( $response['data']['children'] ) ) { - return []; + // .json endpoint failed (likely 403 IP block) — try RSS fallback. + PRAutoBlogger_Logger::instance()->info( + sprintf( 'Reddit .json failed for r/%s — trying RSS fallback.', $subreddit ), + 'reddit_json' + ); + return $this->fetch_posts_via_rss( $subreddit, $limit ); } $posts = []; @@ -86,6 +91,161 @@ class PRAutoBlogger_Reddit_JSON_Client { return $posts; } + /** + * Fetch posts from a subreddit via Reddit's RSS/Atom feed. + * + * RSS endpoints are less commonly blocked by Reddit than .json. + * Returns data in the same format as fetch_posts() for compatibility, + * but some fields (score, num_comments) are unavailable in RSS and + * set to defaults. + * + * Side effects: HTTP request to Reddit. + * + * @param string $subreddit Subreddit name (without r/ prefix). + * @param int $limit Max posts to fetch. + * + * @return array> Array of post data. + */ + private function fetch_posts_via_rss( string $subreddit, int $limit ): array { + $url = sprintf( + '%s/r/%s/hot.rss?limit=%d', + self::BASE_URL, + rawurlencode( $subreddit ), + min( $limit, 100 ) + ); + + $response = wp_remote_get( + $url, + [ + 'timeout' => self::TIMEOUT_SECONDS, + 'headers' => [ + 'User-Agent' => self::USER_AGENT, + 'Accept' => 'application/atom+xml, application/rss+xml', + ], + ] + ); + + if ( is_wp_error( $response ) ) { + PRAutoBlogger_Logger::instance()->error( + 'Reddit RSS GET failed: ' . $response->get_error_message(), + 'reddit_json' + ); + return []; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + PRAutoBlogger_Logger::instance()->error( + sprintf( 'Reddit RSS HTTP %d for r/%s', $status_code, $subreddit ), + 'reddit_json' + ); + return []; + } + + $body = wp_remote_retrieve_body( $response ); + return $this->parse_atom_feed( $body, $subreddit ); + } + + /** + * Parse a Reddit Atom feed into post-data arrays matching .json format. + * + * Maps Atom entry fields to the standard Reddit post fields so the rest + * of the pipeline can consume RSS data without changes. Fields unavailable + * in RSS (score, num_comments, upvote_ratio) are set to reasonable defaults. + * + * @param string $xml Raw Atom XML string. + * @param string $subreddit The subreddit name for context. + * + * @return array> Parsed post data. + */ + private function parse_atom_feed( string $xml, string $subreddit ): array { + // Suppress XML errors for malformed feeds. + $prev_errors = libxml_use_internal_errors( true ); + $feed = simplexml_load_string( $xml ); + libxml_use_internal_errors( $prev_errors ); + + if ( false === $feed ) { + PRAutoBlogger_Logger::instance()->error( 'Failed to parse Reddit RSS XML.', 'reddit_json' ); + return []; + } + + // Register the Atom namespace. + $feed->registerXPathNamespace( 'atom', 'http://www.w3.org/2005/Atom' ); + $entries = $feed->xpath( '//atom:entry' ); + + if ( empty( $entries ) ) { + return []; + } + + $posts = []; + foreach ( $entries as $entry ) { + $title = (string) $entry->title; + $link = ''; + $content = ''; + + // Get the HTML link (type="text/html"). + foreach ( $entry->link as $link_el ) { + if ( 'text/html' === (string) $link_el['type'] || 'alternate' === (string) $link_el['rel'] ) { + $link = (string) $link_el['href']; + break; + } + } + + // Content is in tag as HTML. + if ( isset( $entry->content ) ) { + // Strip HTML tags to get plain text for content field. + $content = wp_strip_all_tags( (string) $entry->content ); + } + + // Extract post ID from the entry id (format: /r/subreddit/comments/ID/...) + $entry_id = (string) $entry->id; + $post_id = ''; + if ( preg_match( '#/comments/([a-z0-9]+)#', $entry_id, $m ) ) { + $post_id = $m[1]; + } + + // Extract author name. + $author = '[deleted]'; + if ( isset( $entry->author->name ) ) { + $author = str_replace( '/u/', '', (string) $entry->author->name ); + } + + // Parse published date to Unix timestamp. + $published = (string) ( $entry->published ?? $entry->updated ?? '' ); + $created_utc = '' !== $published ? (int) strtotime( $published ) : time(); + + // Extract permalink (relative path) from full URL. + $permalink = ''; + if ( '' !== $link ) { + $parsed = wp_parse_url( $link ); + $permalink = $parsed['path'] ?? ''; + } + + $posts[] = [ + 'id' => $post_id, + 'title' => $title, + 'selftext' => $content, + 'author' => $author, + 'score' => 1, // Not available in RSS. + 'num_comments' => 0, // Not available in RSS. + 'permalink' => $permalink, + 'created_utc' => $created_utc, + 'is_self' => true, + 'link_flair_text' => null, + 'upvote_ratio' => null, + 'is_original_content' => false, + 'data_source' => 'reddit_rss', // Tag for debugging. + ]; + } + + PRAutoBlogger_Logger::instance()->info( + sprintf( 'Parsed %d posts from Reddit RSS for r/%s.', count( $posts ), $subreddit ), + 'reddit_json' + ); + + return $posts; + } + /** * Fetch top comments for a specific post via Reddit's .json endpoint. * @@ -128,16 +288,25 @@ class PRAutoBlogger_Reddit_JSON_Client { } /** - * Check if Reddit .json endpoints are reachable from this server. + * Check if Reddit .json or RSS endpoints are reachable from this server. * - * Datacenter IPs are often blocked by Reddit. This quick check lets the - * provider know whether this fallback is actually usable. + * Datacenter IPs are often blocked by Reddit for .json endpoints. + * We check .json first, then fall back to RSS availability. * * Side effects: HTTP request. * - * @return bool True if we got a valid JSON response. + * @return bool True if we got a valid response from either endpoint. */ public function is_available(): bool { + return $this->is_json_available() || $this->is_rss_available(); + } + + /** + * Check if Reddit .json endpoints are reachable. + * + * @return bool True if .json endpoint returns HTTP 200. + */ + public function is_json_available(): bool { $url = self::BASE_URL . '/r/all/hot.json?limit=1'; $response = wp_remote_get( $url, @@ -153,10 +322,33 @@ class PRAutoBlogger_Reddit_JSON_Client { return false; } - $code = wp_remote_retrieve_response_code( $response ); + return 200 === wp_remote_retrieve_response_code( $response ); + } - // Reddit returns 429 when rate-limited and 403 when datacenter IP is blocked. - return 200 === $code; + /** + * Check if Reddit RSS endpoints are reachable. + * + * RSS endpoints are less commonly blocked by Reddit than .json. + * + * @return bool True if RSS endpoint returns HTTP 200 with XML content. + */ + private function is_rss_available(): bool { + $url = self::BASE_URL . '/r/all/hot.rss?limit=1'; + $response = wp_remote_get( + $url, + [ + 'timeout' => 10, + 'headers' => [ + 'User-Agent' => self::USER_AGENT, + ], + ] + ); + + if ( is_wp_error( $response ) ) { + return false; + } + + return 200 === wp_remote_retrieve_response_code( $response ); } /** diff --git a/includes/providers/class-reddit-provider.php b/includes/providers/class-reddit-provider.php index 9dc75ad..8d54430 100755 --- a/includes/providers/class-reddit-provider.php +++ b/includes/providers/class-reddit-provider.php @@ -67,10 +67,23 @@ class PRAutoBlogger_Reddit_Provider implements PRAutoBlogger_Source_Provider_Int $use_pullpush = $this->is_pullpush_available(); $collected = []; + PRAutoBlogger_Logger::instance()->info( + sprintf( + 'Reddit collect_data: %d subreddits=%s, use_pullpush=%s, limit=%d, time=%s', + count( $subreddits ), + wp_json_encode( $subreddits ), + $use_pullpush ? 'yes' : 'no', + $limit, + $time_filter + ), + 'reddit' + ); + foreach ( $subreddits as $subreddit ) { $subreddit = sanitize_text_field( $subreddit ); try { + $before_count = count( $collected ); if ( $use_pullpush ) { $collected = array_merge( $collected, @@ -82,6 +95,11 @@ class PRAutoBlogger_Reddit_Provider implements PRAutoBlogger_Source_Provider_Int $this->collect_from_reddit_json( $subreddit, $limit, $time_filter, $include_comments, $comments_limit ) ); } + $added = count( $collected ) - $before_count; + PRAutoBlogger_Logger::instance()->info( + sprintf( 'Reddit r/%s: fetched %d items via %s', $subreddit, $added, $use_pullpush ? 'pullpush' : 'reddit_json' ), + 'reddit' + ); } catch ( \Exception $e ) { // Log but continue with other subreddits. PRAutoBlogger_Logger::instance()->error( diff --git a/includes/providers/interface-llm-provider.php b/includes/providers/interface-llm-provider.php index c575bc9..28ce07e 100755 --- a/includes/providers/interface-llm-provider.php +++ b/includes/providers/interface-llm-provider.php @@ -7,7 +7,7 @@ declare(strict_types=1); * Any class implementing this interface can be swapped in without changing the * content generation, analysis, or editor logic. * - * @see class-openrouter-provider.php — Primary implementation (OpenRouter). + * @see class-open-router-provider.php — Primary implementation (OpenRouter). * @see CONVENTIONS.md — "How To: Add a New LLM Provider". * @see ARCHITECTURE.md — External API integrations table. */ diff --git a/prautoblogger.php b/prautoblogger.php index 92ce91f..c182ea6 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) { | and limits without magic strings. */ -define( 'PRAUTOBLOGGER_VERSION', '0.2.0' ); +define( 'PRAUTOBLOGGER_VERSION', '0.2.1' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); @@ -49,9 +49,9 @@ define( 'PRAUTOBLOGGER_API_TIMEOUT_SECONDS', 120 ); define( 'PRAUTOBLOGGER_CACHE_TTL_SECONDS', 3600 ); // Default models for OpenRouter (user can override in settings). -define( 'PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL', 'anthropic/claude-3.5-haiku' ); -define( 'PRAUTOBLOGGER_DEFAULT_WRITING_MODEL', 'anthropic/claude-sonnet-4' ); -define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'anthropic/claude-sonnet-4' ); +define( 'PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL', 'google/gemini-2.5-flash-lite' ); +define( 'PRAUTOBLOGGER_DEFAULT_WRITING_MODEL', 'google/gemini-2.5-flash-lite' ); +define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' ); /* |--------------------------------------------------------------------------