commit 68c6f67bcfacc4321ac8f430ff02b73c01df1cfa Author: Terence Ong Date: Fri Apr 10 16:10:27 2026 +0700 Initial commit: AutoBlogger v0.1.0 WordPress plugin that auto-researches topics from Reddit, generates SEO-optimized articles via OpenRouter LLMs, runs them through a chief editor agent, and publishes on a daily schedule. Includes: - Full generation pipeline (collect → analyze → score → generate → review → publish) - Cost tracking and budget enforcement - Admin UI: settings, review queue, metrics, activity log - CI/CD pipeline (GitHub Actions → WordPress REST deploy) - Unit test suite (PHPUnit + Brain Monkey) - Structured logging with configurable verbosity Co-Authored-By: Claude Opus 4.6 diff --git a/.distignore b/.distignore new file mode 100755 index 0000000..1324b23 --- /dev/null +++ b/.distignore @@ -0,0 +1,36 @@ +# Files excluded from the production zip. +# Used by the GitHub Actions deploy workflow (rsync --exclude-from). +# Keep in sync with .gitignore for dev-only artifacts. + +.git +.github +.distignore +.gitignore +.phpcs.xml +.editorconfig + +# Development & testing +tests/ +phpunit.xml +phpunit.xml.dist +composer.json +composer.lock +vendor/ + +# Documentation (lives in repo, not in deployed plugin) +ARCHITECTURE.md +CONVENTIONS.md +CHANGELOG.md +CODE_OF_CONDUCT.md +CONTRIBUTING.md + +# CI/CD tooling +scripts/ +node_modules/ +package.json +package-lock.json + +# OS artifacts +.DS_Store +Thumbs.db +*.log diff --git a/.github/mu-plugins/autoblogger-deploy-receiver.php b/.github/mu-plugins/autoblogger-deploy-receiver.php new file mode 100755 index 0000000..abdb06d --- /dev/null +++ b/.github/mu-plugins/autoblogger-deploy-receiver.php @@ -0,0 +1,236 @@ + 'POST', + 'callback' => 'autoblogger_deploy_handle', + 'permission_callback' => 'autoblogger_deploy_check_auth', + ] ); + + // Health check endpoint (GET) — useful for monitoring. + register_rest_route( 'autoblogger-deploy/v1', '/status', [ + 'methods' => 'GET', + 'callback' => function () { + return [ + 'status' => 'ready', + 'plugin' => is_dir( WP_PLUGIN_DIR . '/autoblogger' ) ? 'installed' : 'not_installed', + 'active' => is_plugin_active( 'autoblogger/autoblogger.php' ), + 'version' => autoblogger_deploy_get_installed_version(), + ]; + }, + 'permission_callback' => 'autoblogger_deploy_check_auth', + ] ); +} + +/** + * Validate the deploy key from the request header. + * Uses hash_equals() for timing-safe comparison. + */ +function autoblogger_deploy_check_auth( WP_REST_Request $request ): bool { + $key = $request->get_header( 'X-Deploy-Key' ); + if ( null === $key || '' === $key ) { + return false; + } + return hash_equals( (string) AUTOBLOGGER_DEPLOY_KEY, $key ); +} + +/** + * Handle a deploy request: receive zip, validate, extract, activate. + */ +function autoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Response|WP_Error { + // Rate limit: 1 deploy per 60 seconds. + $lock = get_transient( 'autoblogger_deploy_lock' ); + if ( false !== $lock ) { + return new WP_Error( + 'rate_limited', + 'Deploy in progress or recently completed. Wait 60 seconds.', + [ 'status' => 429 ] + ); + } + set_transient( 'autoblogger_deploy_lock', time(), 60 ); + + // 1. Validate uploaded file exists. + $files = $request->get_file_params(); + if ( empty( $files['plugin']['tmp_name'] ) || ! is_uploaded_file( $files['plugin']['tmp_name'] ) ) { + delete_transient( 'autoblogger_deploy_lock' ); + return new WP_Error( 'no_file', 'No plugin zip uploaded.', [ 'status' => 400 ] ); + } + + $tmp_file = $files['plugin']['tmp_name']; + + // 2. Validate it's a real zip and contains our plugin. + $zip = new ZipArchive(); + if ( true !== $zip->open( $tmp_file ) ) { + delete_transient( 'autoblogger_deploy_lock' ); + return new WP_Error( 'invalid_zip', 'Uploaded file is not a valid zip.', [ 'status' => 400 ] ); + } + + $has_main_file = false; + for ( $i = 0; $i < $zip->numFiles; $i++ ) { + if ( 'autoblogger/autoblogger.php' === $zip->getNameIndex( $i ) ) { + $has_main_file = true; + break; + } + } + + if ( ! $has_main_file ) { + $zip->close(); + delete_transient( 'autoblogger_deploy_lock' ); + return new WP_Error( + 'invalid_plugin', + 'Zip must contain autoblogger/autoblogger.php at root.', + [ 'status' => 400 ] + ); + } + + // 3. Read version from the incoming zip before extraction. + $incoming_main = $zip->getFromName( 'autoblogger/autoblogger.php' ); + $incoming_version = 'unknown'; + if ( $incoming_main && preg_match( '/Version:\s*([0-9]+\.[0-9]+\.[0-9]+)/', $incoming_main, $m ) ) { + $incoming_version = $m[1]; + } + + $plugin_dir = WP_PLUGIN_DIR . '/autoblogger'; + $backup_dir = null; + + // 4. Deactivate plugin before file operations (prevent fatal errors). + $was_active = is_plugin_active( 'autoblogger/autoblogger.php' ); + if ( $was_active ) { + deactivate_plugins( 'autoblogger/autoblogger.php', true ); + } + + // 5. Backup current installation if it exists. + if ( is_dir( $plugin_dir ) ) { + $backup_dir = WP_PLUGIN_DIR . '/autoblogger-backup-' . time(); + if ( ! rename( $plugin_dir, $backup_dir ) ) { + $zip->close(); + // Re-activate if we deactivated. + if ( $was_active ) { + activate_plugin( 'autoblogger/autoblogger.php' ); + } + delete_transient( 'autoblogger_deploy_lock' ); + return new WP_Error( 'backup_failed', 'Could not backup current plugin.', [ 'status' => 500 ] ); + } + } + + // 6. Extract the zip. + $extract_ok = $zip->extractTo( WP_PLUGIN_DIR ); + $zip->close(); + + if ( ! $extract_ok || ! file_exists( $plugin_dir . '/autoblogger.php' ) ) { + // Rollback: restore backup. + if ( $backup_dir && is_dir( $backup_dir ) ) { + rename( $backup_dir, $plugin_dir ); + } + if ( $was_active ) { + activate_plugin( 'autoblogger/autoblogger.php' ); + } + delete_transient( 'autoblogger_deploy_lock' ); + error_log( '[AutoBlogger Deploy] Extraction failed. Rolled back to previous version.' ); + return new WP_Error( 'extract_failed', 'Zip extraction failed. Rolled back.', [ 'status' => 500 ] ); + } + + // 7. Activate plugin. + $activation_result = activate_plugin( 'autoblogger/autoblogger.php' ); + if ( is_wp_error( $activation_result ) ) { + // Activation failed — rollback. + autoblogger_deploy_rmdir_recursive( $plugin_dir ); + if ( $backup_dir && is_dir( $backup_dir ) ) { + rename( $backup_dir, $plugin_dir ); + activate_plugin( 'autoblogger/autoblogger.php' ); + } + delete_transient( 'autoblogger_deploy_lock' ); + error_log( '[AutoBlogger Deploy] Activation failed: ' . $activation_result->get_error_message() . '. Rolled back.' ); + return new WP_Error( + 'activation_failed', + 'Plugin activation failed: ' . $activation_result->get_error_message() . '. Rolled back.', + [ 'status' => 500 ] + ); + } + + // 8. Clean up backup. + if ( $backup_dir && is_dir( $backup_dir ) ) { + autoblogger_deploy_rmdir_recursive( $backup_dir ); + } + + $old_version = autoblogger_deploy_get_installed_version(); + error_log( sprintf( + '[AutoBlogger Deploy] Successfully deployed v%s (was: %s) at %s', + $incoming_version, + $old_version ?: 'fresh install', + gmdate( 'Y-m-d H:i:s' ) + ) ); + + return new WP_REST_Response( [ + 'success' => true, + 'version' => $incoming_version, + 'message' => "AutoBlogger v{$incoming_version} deployed and activated.", + ], 200 ); +} + +/** + * Get the currently installed plugin version from the main file header. + */ +function autoblogger_deploy_get_installed_version(): string { + $main_file = WP_PLUGIN_DIR . '/autoblogger/autoblogger.php'; + if ( ! file_exists( $main_file ) ) { + return ''; + } + $data = get_file_data( $main_file, [ 'Version' => 'Version' ] ); + return $data['Version'] ?? ''; +} + +/** + * Recursively delete a directory. + */ +function autoblogger_deploy_rmdir_recursive( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ( $iterator as $file ) { + if ( $file->isDir() ) { + rmdir( $file->getPathname() ); + } else { + unlink( $file->getPathname() ); + } + } + rmdir( $dir ); +} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100755 index 0000000..ede72f5 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,159 @@ +# AutoBlogger CI/CD Pipeline +# +# Triggers on every push to main. Runs PHP lint and coding standards checks, +# packages the plugin into a clean zip (excluding dev files), and deploys it +# to the live WordPress site via the deploy-receiver mu-plugin REST endpoint. +# +# Required GitHub Secrets: +# DEPLOY_URL — https://peptiderepo.com/wp-json/autoblogger-deploy/v1/deploy +# DEPLOY_KEY — The token matching AUTOBLOGGER_DEPLOY_KEY in wp-config.php +# +# @see .github/mu-plugins/autoblogger-deploy-receiver.php — Server-side endpoint. +# @see scripts/deploy.sh — Manual deploy script. + +name: Deploy AutoBlogger + +on: + push: + branches: [main] + workflow_dispatch: # Allow manual trigger from GitHub UI + +concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ────────────────────────────────────────────────────────────────────── + # Stage 1: Lint & Standards + # ────────────────────────────────────────────────────────────────────── + lint: + name: PHP Lint & PHPCS + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + tools: cs2pr, phpcs + coverage: none + + - name: PHP syntax check + run: | + find . -name '*.php' -not -path './vendor/*' -not -path './tests/*' \ + -exec php -l {} \; 2>&1 | grep -v "No syntax errors" | head -50 + # Fail if any file has syntax errors + find . -name '*.php' -not -path './vendor/*' -not -path './tests/*' \ + -exec php -l {} \; 2>&1 | grep -q "Parse error" && exit 1 || true + + - name: Install WordPress Coding Standards + run: | + composer global config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true + composer global require --dev wp-coding-standards/wpcs:"^3.0" \ + phpcsstandards/phpcsextra:"^1.0" \ + phpcsstandards/phpcsutils:"^1.0" + + - name: Run PHPCS (warnings only, non-blocking) + run: | + phpcs --standard=WordPress --extensions=php \ + --ignore=vendor,tests,node_modules \ + --report=checkstyle \ + --warning-severity=0 \ + . | cs2pr || true + # Non-blocking for now — we want visibility without breaking deploys + # while we clean up existing code. Remove "|| true" once clean. + + # ────────────────────────────────────────────────────────────────────── + # Stage 2: Unit Tests + # ────────────────────────────────────────────────────────────────────── + test: + name: PHPUnit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + extensions: openssl, json, mbstring + coverage: none + + - name: Install Composer dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run PHPUnit + run: vendor/bin/phpunit --testdox + + # ────────────────────────────────────────────────────────────────────── + # Stage 3: Package & Deploy + # ────────────────────────────────────────────────────────────────────── + deploy: + name: Package & Deploy to Production + runs-on: ubuntu-latest + needs: [lint, test] + if: github.ref == 'refs/heads/main' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Read plugin version + id: version + run: | + VERSION=$(grep -oP "Version:\s*\K[0-9]+\.[0-9]+\.[0-9]+" autoblogger.php) + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Plugin version: $VERSION" + + - name: Build plugin zip + run: | + # Create a clean build directory + mkdir -p build/autoblogger + + # Copy only production files (exclude dev/CI artifacts) + rsync -av --exclude-from='.distignore' ./ build/autoblogger/ + + # Create the zip + cd build + zip -r "autoblogger-${{ steps.version.outputs.version }}.zip" autoblogger/ + ls -lh "autoblogger-${{ steps.version.outputs.version }}.zip" + + - name: Deploy to WordPress + env: + DEPLOY_URL: ${{ secrets.DEPLOY_URL }} + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} + run: | + VERSION="${{ steps.version.outputs.version }}" + ZIP_PATH="build/autoblogger-${VERSION}.zip" + + echo "Deploying AutoBlogger v${VERSION} to production..." + + RESPONSE=$(curl -sS -w "\n%{http_code}" \ + -X POST "${DEPLOY_URL}" \ + -H "X-Deploy-Key: ${DEPLOY_KEY}" \ + -F "plugin=@${ZIP_PATH}" \ + --max-time 120) + + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | head -n -1) + + echo "HTTP ${HTTP_CODE}: ${BODY}" + + if [ "$HTTP_CODE" -ne 200 ]; then + echo "::error::Deployment failed with HTTP ${HTTP_CODE}" + exit 1 + fi + + echo "Deployment successful!" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: autoblogger-${{ steps.version.outputs.version }} + path: build/autoblogger-${{ steps.version.outputs.version }}.zip + retention-days: 30 diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..6f1408f --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Dependencies +vendor/ +node_modules/ + +# Build artifacts +build/ +*.zip + +# IDE / OS +.idea/ +.vscode/ +.DS_Store +Thumbs.db +*.swp +*.swo + +# Environment +.env +.env.local + +# Logs +*.log +debug.log + +# WordPress (if developing inside a WP install) +wp-config.php +wp-content/uploads/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100755 index 0000000..4a33ab3 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,333 @@ +# AutoBlogger — Architecture + +AutoBlogger is a WordPress plugin that monitors social media (starting with Reddit) for recurring questions, complaints, and comparisons in a configured niche, uses LLM agents (via OpenRouter) to generate high-quality blog articles from those insights, runs them through a chief editor agent for quality review, and auto-publishes them on a configurable daily schedule. All collected data and generation metrics are stored for a self-improvement feedback loop. + +--- + +## Data Flow + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ WP-Cron / Action Scheduler │ +│ (Daily trigger, configurable) │ +└────────────┬────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ 1. Source Collector │ Pulls raw posts/comments from social platforms +│ (Reddit API, etc.) │ Stores raw data in `ab_source_data` table +└────────────┬────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ 2. Content Analyzer │ LLM call (cheap model) to detect: +│ (Analysis Agent) │ - Recurring questions +│ │ - Complaints / pain points +│ │ - Product comparisons +│ │ Stores analysis in `ab_analysis_results` table +│ │ Outputs ranked article ideas +└────────────┬────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ 3. Idea Scorer & │ Deduplicates against existing posts +│ Deduplicator │ Scores ideas by relevance, frequency, freshness +│ │ Picks top N ideas (N = daily article target) +└────────────┬────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ 4. Writer Agent │ Configurable pipeline: +│ (Content Generator) │ Single-pass: one LLM call → complete article +│ │ Multi-step: outline → draft → polish +│ │ Uses quality model via OpenRouter +└────────────┬────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ 5. Chief Editor Agent │ LLM-powered editorial review: +│ │ - Checks quality, accuracy, tone +│ │ - Checks SEO (title, headings, keyword density) +│ │ - Can request rewrites or approve +│ │ - Approved → publish; Rejected → flag for human +└────────────┬────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ 6. Publisher │ Creates/updates WordPress post +│ │ Sets categories, tags, featured image +│ │ Stores generation metadata in post_meta +│ │ Logs cost/tokens in `ab_generation_log` table +└────────────┬────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ 7. Metrics Collector │ Tracks post-publish performance: +│ (Separate cron job) │ - WordPress native (views, comments) +│ │ - GA4 (pageviews, bounce rate, time on page) +│ │ - Composite "content score" +│ │ Feeds back into Analysis step for self-improvement +└─────────────────────────┘ +``` + +--- + +## File Tree + +``` +autoblogger/ +├── autoblogger.php # Plugin bootstrap — minimal, only hook registration +├── uninstall.php # Clean removal of ALL plugin data +├── readme.txt # WordPress.org standard readme +├── composer.json # Autoloading (PSR-4) and dependencies +├── ARCHITECTURE.md # This file +├── CONVENTIONS.md # Naming patterns, extension guides +├── CHANGELOG.md # Semantic versioning changelog +│ +├── assets/ +│ ├── css/ +│ │ └── admin.css # Admin page styles (wp-admin conventions) +│ └── js/ +│ └── admin.js # Admin page interactivity (vanilla JS / Alpine.js) +│ +├── includes/ +│ ├── class-autoblogger.php # Main orchestrator — registers all hooks via loader +│ ├── class-activator.php # Activation: create DB tables, set defaults, schedule cron +│ ├── class-deactivator.php # Deactivation: clear cron, cleanup transients +│ ├── class-autoloader.php # PSR-4-style autoloader for plugin classes +│ │ +│ ├── admin/ +│ │ ├── class-admin-page.php # Main settings page (tabbed SaaS-style UI) +│ │ ├── class-settings-fields.php # Declarative settings definitions (sections + fields) +│ │ ├── class-admin-notices.php # Onboarding notices, error alerts, budget warnings +│ │ ├── class-dashboard-widget.php # WP Dashboard widget showing generation status +│ │ ├── class-post-metabox.php # Metabox on posts showing generation metadata +│ │ ├── class-metrics-page.php # Admin page for cost dashboard and content scores +│ │ ├── class-review-queue.php # Approve/edit/reject queue for generated drafts +│ │ └── class-log-viewer.php # Activity log viewer with level filtering +│ │ +│ ├── core/ +│ │ ├── class-scheduler.php # WP-Cron / Action Scheduler job management +│ │ ├── class-source-collector.php # Orchestrates data collection from all sources +│ │ ├── class-content-analyzer.php # LLM-powered analysis of collected social data +│ │ ├── class-idea-scorer.php # Ranks and deduplicates article ideas +│ │ ├── class-content-generator.php # Writer agent — manages the generation pipeline +│ │ ├── class-chief-editor.php # Editor agent — LLM-powered editorial review +│ │ ├── class-publisher.php # Creates WordPress posts from approved content +│ │ ├── class-cost-tracker.php # Logs all API costs, enforces budget limits +│ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug) +│ │ ├── class-pipeline-runner.php # Orchestrates the full generation pipeline +│ │ ├── class-ga4-client.php # Google Analytics 4 API client (OAuth + Data API) +│ │ └── class-metrics-collector.php # Collects post performance data (WP + GA4) +│ │ +│ ├── 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 +│ │ ├── interface-source-provider.php # Contract for any social media source +│ │ ├── class-reddit-provider.php # Reddit API (OAuth) implementation +│ │ ├── class-reddit-api-client.php # Reddit HTTP client (OAuth + rate limiting) +│ │ ├── class-tiktok-provider.php # TikTok — stub/future implementation +│ │ ├── class-instagram-provider.php # Instagram — stub/future implementation +│ │ └── class-youtube-provider.php # YouTube — stub/future implementation +│ │ +│ └── models/ +│ ├── class-source-data.php # Value object: raw social media post/comment +│ ├── class-analysis-result.php # Value object: analyzed topic with scores +│ ├── class-article-idea.php # Value object: scored article idea +│ ├── class-content-request.php # Value object: generation request with params +│ ├── class-editorial-review.php # Value object: editor verdict, scores, revised content +│ ├── class-generation-log.php # Value object: log entry for a generation run +│ └── class-content-score.php # Value object: composite performance score +│ +├── templates/ +│ └── admin/ +│ ├── settings-page.php # Settings page template (tabbed sidebar layout) +│ ├── metrics-page.php # Metrics/cost dashboard template +│ ├── review-queue.php # Review queue table template +│ ├── log-viewer.php # Activity log viewer template +│ └── metabox-generation-info.php # Post metabox template +│ +├── languages/ +│ └── autoblogger.pot # i18n translation template +│ +└── tests/ + ├── unit/ # PHPUnit tests (mocked dependencies) + └── integration/ # WordPress integration tests +``` + +--- + +## Database Schema + +### Custom Tables + +All tables use `$wpdb->prefix` + `autoblogger_` prefix. + +#### `ab_source_data` — Raw social media data +| Column | Type | Description | +|-----------------|-------------------|------------------------------------------------| +| id | BIGINT UNSIGNED | Auto-increment PK | +| source_type | VARCHAR(50) | 'reddit', 'tiktok', 'instagram', 'youtube' | +| source_id | VARCHAR(255) | Platform-specific unique ID (post/comment ID) | +| subreddit | VARCHAR(255) | Subreddit or channel name (nullable) | +| title | TEXT | Post/video title | +| content | LONGTEXT | Post body / comment text / transcript | +| author | VARCHAR(255) | Original author username | +| score | INT | Upvotes/likes/engagement metric | +| comment_count | INT | Number of comments/replies | +| permalink | VARCHAR(500) | URL to original content | +| collected_at | DATETIME | When we collected this data | +| metadata_json | LONGTEXT | Platform-specific extra data (JSON) | +| INDEX | source_type, collected_at | +| UNIQUE INDEX | source_type, source_id | + +#### `ab_analysis_results` — Analyzed topics and patterns +| Column | Type | Description | +|-----------------|-------------------|------------------------------------------------| +| id | BIGINT UNSIGNED | Auto-increment PK | +| analysis_type | VARCHAR(50) | 'question', 'complaint', 'comparison' | +| topic | VARCHAR(500) | Detected topic/theme | +| summary | TEXT | LLM-generated summary of the pattern | +| frequency | INT | How many source posts mention this | +| relevance_score | FLOAT | LLM-assigned relevance score (0-1) | +| source_ids_json | LONGTEXT | JSON array of ab_source_data.id references | +| analyzed_at | DATETIME | When analysis was performed | +| metadata_json | LONGTEXT | Extra analysis data (JSON) | +| INDEX | analysis_type, relevance_score | +| INDEX | analyzed_at | + +#### `ab_generation_log` — API cost and generation tracking +| Column | Type | Description | +|------------------|-------------------|-----------------------------------------------| +| id | BIGINT UNSIGNED | Auto-increment PK | +| post_id | BIGINT UNSIGNED | WordPress post ID (nullable, set after publish)| +| run_id | VARCHAR(36) | UUID linking all entries from one pipeline run | +| stage | VARCHAR(50) | 'analysis', 'outline', 'draft', 'edit', 'review' | +| provider | VARCHAR(50) | 'openrouter' | +| model | VARCHAR(100) | Model identifier used | +| prompt_tokens | INT | Input tokens consumed | +| completion_tokens| INT | Output tokens generated | +| estimated_cost | DECIMAL(10,6) | Estimated USD cost | +| request_json | LONGTEXT | Full request payload (for debugging) | +| response_status | VARCHAR(20) | 'success', 'error', 'timeout' | +| error_message | TEXT | Error details if failed (nullable) | +| created_at | DATETIME | When the API call was made | +| INDEX | post_id | +| INDEX | run_id | +| INDEX | created_at | +| INDEX | stage | + +#### `ab_event_log` — Structured application logging +| Column | Type | Description | +|------------------|-------------------|-----------------------------------------------| +| id | BIGINT UNSIGNED | Auto-increment PK | +| level | VARCHAR(10) | 'error', 'warning', 'info', 'debug' | +| context | VARCHAR(100) | Origin: 'pipeline', 'reddit', 'ga4', etc. | +| message | TEXT | Human-readable log message | +| meta_json | LONGTEXT | Optional structured data (nullable) | +| created_at | DATETIME | When the event occurred | +| INDEX | level, created_at | +| INDEX | created_at | + +#### `ab_content_scores` — Post performance metrics +| Column | Type | Description | +|------------------|-------------------|-----------------------------------------------| +| id | BIGINT UNSIGNED | Auto-increment PK | +| post_id | BIGINT UNSIGNED | WordPress post ID | +| pageviews | INT | Total pageviews (WP native or GA4) | +| avg_time_on_page | FLOAT | Average seconds on page (GA4) | +| bounce_rate | FLOAT | Bounce rate percentage (GA4) | +| comment_count | INT | WordPress comment count | +| composite_score | FLOAT | LLM-computed content quality score (0-100) | +| score_factors_json| LONGTEXT | JSON breakdown of what contributed to score | +| measured_at | DATETIME | When metrics were collected | +| INDEX | post_id | +| INDEX | measured_at | +| INDEX | composite_score | + +### WordPress Options (`wp_options`) + +All prefixed with `autoblogger_`: + +| Option Key | Description | +|-------------------------------------|------------------------------------------------------| +| `autoblogger_openrouter_api_key` | Encrypted OpenRouter API key | +| `autoblogger_reddit_client_id` | Reddit OAuth client ID | +| `autoblogger_reddit_client_secret` | Encrypted Reddit client secret | +| `autoblogger_reddit_access_token` | Reddit OAuth access token (transient-backed) | +| `autoblogger_ga4_property_id` | Google Analytics 4 property ID | +| `autoblogger_ga4_credentials_json` | Encrypted GA4 service account credentials | +| `autoblogger_analysis_model` | OpenRouter model for analysis (default: cheap) | +| `autoblogger_writing_model` | OpenRouter model for writing (default: quality) | +| `autoblogger_editor_model` | OpenRouter model for chief editor (default: quality) | +| `autoblogger_daily_article_target` | Number of articles per day (1-10, default: 1) | +| `autoblogger_writing_pipeline` | 'single_pass' or 'multi_step' (default: multi_step) | +| `autoblogger_niche_description` | Text description of the site's niche | +| `autoblogger_target_subreddits` | JSON array of subreddits to monitor | +| `autoblogger_monthly_budget_usd` | Monthly API spend limit in USD | +| `autoblogger_tone` | Content tone (informational, conversational, etc.) | +| `autoblogger_min_word_count` | Minimum article word count (default: 800) | +| `autoblogger_max_word_count` | Maximum article word count (default: 2000) | +| `autoblogger_topic_exclusions` | JSON array of topics to never write about | +| `autoblogger_enabled_sources` | JSON array of active source types | +| `autoblogger_auto_publish` | Toggle: auto-publish approved posts (default: '0') | +| `autoblogger_default_author` | WP user ID for generated post authorship | +| `autoblogger_default_category` | WP category ID fallback for generated posts | +| `autoblogger_log_level` | Logging threshold: error/warning/info/debug | +| `autoblogger_db_version` | Schema version for migrations | +| `autoblogger_schedule_time` | Daily generation time (HH:MM, default: '03:00') | + +### Post Meta + +Stored on every AutoBlogger-generated post: + +| Meta Key | Description | +|---------------------------------------|----------------------------------------------| +| `_autoblogger_generated` | Boolean flag — '1' if generated by plugin | +| `_autoblogger_analysis_id` | FK to ab_analysis_results.id | +| `_autoblogger_source_ids` | JSON array of source data IDs used | +| `_autoblogger_model_used` | Model that generated the content | +| `_autoblogger_pipeline_mode` | 'single_pass' or 'multi_step' | +| `_autoblogger_total_cost` | Total USD cost for this post | +| `_autoblogger_total_tokens` | Total tokens consumed for this post | +| `_autoblogger_editor_verdict` | 'approved', 'revised', 'rejected' | +| `_autoblogger_editor_notes` | Chief editor's review notes | +| `_autoblogger_generated_at` | ISO 8601 timestamp of generation | +| `_autoblogger_research_sources` | JSON array of source URLs used | + +--- + +## External API Integrations + +| API | Purpose | Code Location | +|----------------|-----------------------------------|----------------------------------------| +| OpenRouter | All LLM calls (analysis, writing, editing) | `providers/class-openrouter-provider.php`, `providers/class-openrouter-pricing.php` | +| Reddit API | Pull posts/comments from subreddits | `providers/class-reddit-provider.php`, `providers/class-reddit-api-client.php` | +| Google Analytics 4 | Post performance metrics | `core/class-ga4-client.php`, `core/class-metrics-collector.php` | + +--- + +## Key Decisions + +1. **OpenRouter over direct provider APIs.** Gives access to many models through one API, simplifies provider management, and lets the user switch models without code changes. Trade-off: slight latency overhead and dependency on OpenRouter's availability. + +2. **Reddit OAuth API over JSON scraping.** Official API is reliable, rate-limited in a predictable way, and won't break unexpectedly. Requires user to create a Reddit app (we document this). Trade-off: more setup friction for the user. + +3. **Chief editor agent instead of human review queue.** The user wants full automation — a second LLM pass reviews quality, SEO, and accuracy before publishing. Posts that fail editorial review are flagged for human intervention rather than published. Trade-off: doubles the LLM cost of the "review" step, but catches quality issues. + +4. **Configurable pipeline (single-pass vs. multi-step).** Some users want cheap/fast, others want high quality. Making this configurable avoids forcing one approach. Trade-off: more code paths to maintain. + +5. **Composite content score using LLM.** Rather than just tracking raw metrics, we periodically have the LLM evaluate what made high-performing posts succeed and low-performing posts fail. This feeds back into the analysis and generation prompts. Trade-off: additional API cost for scoring, but enables the self-improvement loop. + +6. **Source provider interface for future platforms.** Reddit is first, but TikTok/Instagram/YouTube are planned. The interface pattern means adding a new source is one class implementation. Trade-off: slight over-engineering for day one, but pays off immediately at source #2. + +7. **Custom tables for high-volume data.** Source data, analysis results, and generation logs are high-write, time-series data. WordPress post_meta and options are wrong for this. Custom tables with proper indexes. Trade-off: more complex activation/uninstall, but correct. + +8. **All API keys encrypted at rest.** Using `wp_salt()` as encryption key with OpenSSL. Not bulletproof (salt is in wp-config.php) but significantly better than plaintext in wp_options. Trade-off: adds complexity to option get/set, but necessary for security. + +9. **Structured logger instead of raw error_log().** All application logging flows through `Autoblogger_Logger` singleton, which writes to the `ab_event_log` table and forwards errors/warnings to PHP's `error_log()`. This gives users an in-admin Activity Log page with filtering by level, search, and pagination — much more accessible than server logs. Trade-off: one extra DB write per log entry, but the table is prunable and indexed. + +10. **Database-level atomic mutex for generation lock.** Uses `INSERT IGNORE` on `wp_options` (which has a UNIQUE index on `option_name`) instead of transient-based locking. This eliminates the TOCTOU race condition where two concurrent cron runs could both read the transient as "not locked" before either sets it. Expired locks older than 1 hour are cleaned up first to prevent permanent deadlock. + +11. **Run-ID based log linking.** Each pipeline execution generates a UUID (`run_id`) that tags every `ab_generation_log` entry. When a post is published, `link_generation_logs()` uses `UPDATE WHERE run_id = X` to associate entries with the post_id. This is more reliable than the previous timestamp-window approach, especially in batch runs where multiple posts are generated in quick succession. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100755 index 0000000..3059957 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +All notable changes to AutoBlogger will be documented in this file. + +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project uses [Semantic Versioning](https://semver.org/). + +## [0.1.0] — 2026-04-10 + +### Added +- Initial plugin scaffold with WordPress-native architecture. +- OpenRouter LLM provider with configurable model selection and retry logic. +- Reddit API source provider (OAuth2 script app) for collecting posts and comments. +- Content analysis engine: detects recurring questions, complaints, and comparisons. +- Idea scorer with deduplication against existing posts. +- Configurable writer agent pipeline (single-pass or multi-step: outline → draft → polish). +- Chief editor agent for LLM-powered editorial review before publishing. +- Publisher with full generation metadata stored as post_meta. +- Cost tracker with per-call logging, monthly budget enforcement, and hard-stop. +- Metrics collector: WordPress native + GA4 integration + composite content scoring. +- Admin settings page with sections for API keys, models, content, sources, schedule, and analytics. +- Metrics dashboard showing monthly spend, budget utilization, cost by stage, and daily spend. +- Post metabox showing generation metadata on AutoBlogger-generated posts. +- Admin notices for missing API keys, budget warnings, and configuration hints. +- Stub providers for TikTok, Instagram, and YouTube (future implementation). +- Encrypted storage for API keys using AES-256-CBC with wp_salt(). +- AJAX-powered "Generate Now" and "Test Connections" buttons. +- Custom database tables for source data, analysis results, generation logs, and content scores. +- Clean uninstall handler removing all plugin data. +- ARCHITECTURE.md and CONVENTIONS.md for AI-readable codebase documentation. diff --git a/CONVENTIONS.md b/CONVENTIONS.md new file mode 100755 index 0000000..cf6b56d --- /dev/null +++ b/CONVENTIONS.md @@ -0,0 +1,168 @@ +# AutoBlogger — Conventions + +This document codifies the naming patterns, coding conventions, and step-by-step guides for extending AutoBlogger. Update this file whenever a new pattern is introduced. + +--- + +## Naming Conventions + +### PHP Classes +- **Format:** `Autoblogger_{PascalCase}` (e.g., `Autoblogger_Content_Generator`) +- **File naming:** `class-{kebab-case}.php` (e.g., `class-content-generator.php`) +- **Interfaces:** `interface-{kebab-case}.php` with `Autoblogger_{Name}_Interface` class name + +### Hooks (Actions & Filters) +- **Prefix:** All hooks start with `autoblogger_` +- **Actions:** `autoblogger_{event}` (e.g., `autoblogger_before_content_generation`) +- **Filters:** `autoblogger_filter_{what}` (e.g., `autoblogger_filter_article_idea_score`) +- **Hook callbacks registered in:** `class-autoblogger.php` (central loader), never in the classes themselves + +### Database +- **Table prefix:** `{$wpdb->prefix}autoblogger_` (e.g., `wp_autoblogger_source_data`) +- **Option prefix:** `autoblogger_` (e.g., `autoblogger_openrouter_api_key`) +- **Post meta prefix:** `_autoblogger_` (underscore prefix hides from custom fields UI) +- **Transient prefix:** `autoblogger_` (e.g., `autoblogger_reddit_token`) + +### Constants +- **Format:** `AUTOBLOGGER_{UPPER_SNAKE}` (e.g., `AUTOBLOGGER_MAX_RETRIES`) +- **Defined in:** `autoblogger.php` (bootstrap file) for global constants + +### Hook Callback Methods +- **Action callbacks:** Prefixed with `on_` (e.g., `on_daily_generation_triggered`) +- **Filter callbacks:** Prefixed with `filter_` (e.g., `filter_content_quality`) + +--- + +## Error Handling & Logging + +### Structured Logger +All logging uses `Autoblogger_Logger` (singleton) instead of raw `error_log()`. The logger writes to the `ab_event_log` custom table and forwards errors/warnings to PHP's `error_log()` for server-level monitoring. The verbosity threshold is user-configurable via Settings → Publishing → Log Level. + +**Never call `error_log()` directly.** Always use the Logger: + +```php +$logger = Autoblogger_Logger::instance(); +$logger->error( 'API call failed: ' . $error_message, 'openrouter' ); +$logger->warning( 'Budget nearing limit.', 'cost-tracker' ); +$logger->info( 'Pipeline complete: 3 posts generated.', 'pipeline' ); +$logger->debug( 'Token counts: prompt=1200, completion=800', 'openrouter' ); +``` + +### Log levels (ascending verbosity) +| Level | Numeric | Use for | +|---------|---------|---------| +| error | 0 | Failures: API errors, parse failures, DB write failures | +| warning | 1 | Degraded behavior: budget approaching, missing providers, fallback pricing | +| info | 2 | Key events: pipeline start/stop, articles generated, metrics collected | +| debug | 3 | Verbose detail: token counts, timing, skipped posts, intermediate values | + +### Context strings +The second argument to every log call is a short context tag identifying the origin: +`pipeline`, `scheduler`, `collector`, `analyzer`, `editor`, `openrouter`, `reddit`, `ga4`, `metrics`, `cost-tracker`, `encryption`. + +### What gets shown to the user +- API key issues → admin notice (persistent until resolved) +- Budget exceeded → admin notice + email to site admin +- Generation failures → logged to `ab_generation_log` with `response_status = 'error'` +- Generation failures also shown in the admin dashboard widget +- All log entries visible in AutoBlogger → Activity Log (filterable by level) + +### What gets retried +- LLM API calls: exponential backoff, max 3 retries, then fail loudly +- Reddit API calls: exponential backoff, max 3 retries, then skip this collection cycle +- **Never retry silently.** Every retry is logged. Every final failure is surfaced. + +### Error severity levels +1. **Fatal:** Plugin cannot function (missing API key, budget exhausted) → admin notice, stop all scheduled jobs +2. **Retriable:** Transient API failure → retry with backoff, log each attempt +3. **Skippable:** Single post generation failed → log, skip this post, continue with others + +--- + +## How To: Add a New LLM Provider + +1. Create `includes/providers/class-{name}-provider.php` +2. Implement `Autoblogger_LLM_Provider_Interface` (see `interface-llm-provider.php`) +3. Required methods: `send_chat_completion()`, `get_available_models()`, `estimate_cost()` +4. Add provider option to the settings page in `admin/class-admin-page.php` +5. Register the provider in `class-autoblogger.php` loader +6. Update ARCHITECTURE.md external API integrations table + +### Interface contract: +```php +interface Autoblogger_LLM_Provider_Interface { + public function send_chat_completion(array $messages, string $model, array $options = []): array; + public function get_available_models(): array; + public function estimate_cost(string $model, int $prompt_tokens, int $completion_tokens): float; + public function get_provider_name(): string; +} +``` + +--- + +## How To: Add a New Source Provider (Social Platform) + +1. Create `includes/providers/class-{platform}-provider.php` +2. Implement `Autoblogger_Source_Provider_Interface` (see `interface-source-provider.php`) +3. Required methods: `collect_data()`, `get_source_type()`, `validate_credentials()` +4. Add API credentials fields to the settings page in `admin/class-admin-page.php` +5. Register the provider in `class-autoblogger.php` loader +6. Add the source type string to the `ab_source_data.source_type` allowed values +7. Update ARCHITECTURE.md file tree and external API integrations table + +### Interface contract: +```php +interface Autoblogger_Source_Provider_Interface { + public function collect_data(array $config): array; // Returns Autoblogger_Source_Data[] + public function get_source_type(): string; // e.g., 'reddit', 'tiktok' + public function validate_credentials(): bool; + public function get_rate_limit_status(): array; +} +``` + +--- + +## How To: Add a New Admin Setting + +1. Define the option key in the settings array in `admin/class-admin-page.php` → `get_settings_fields()` method +2. Format: add an entry to the appropriate section array: + ```php + [ + 'id' => 'autoblogger_new_setting', + 'label' => __('Setting Label', 'autoblogger'), + 'type' => 'text', // text, number, select, textarea, checkbox, password + 'default' => '', + 'description' => __('Help text shown below the field.', 'autoblogger'), + 'sanitize' => 'sanitize_text_field', // sanitization callback + ] + ``` +3. The settings page renderer handles registration, rendering, and sanitization automatically from this array. +4. Access the setting anywhere with: `get_option('autoblogger_new_setting', $default)` +5. For encrypted settings (API keys): use `Autoblogger_Encryption::encrypt()` / `decrypt()` + +--- + +## How To: Add a New Pipeline Stage + +The writing pipeline is an ordered array of stages. Each stage is a class method on `Content_Generator`. + +1. Add the stage method to `class-content-generator.php`: `protected function stage_{name}(Autoblogger_Content_Request $request): string` +2. Register the stage in the `get_pipeline_stages()` method for the appropriate mode (single_pass or multi_step) +3. Each stage receives the accumulated context and returns its output +4. Each stage logs its cost via `Cost_Tracker::log_api_call()` +5. If a stage fails after retries, the entire pipeline fails (no partial publishes) + +--- + +## Code Quality Rules + +1. **`declare(strict_types=1);`** in every PHP file +2. **Type declarations** on all method parameters, return types, and class properties +3. **No file exceeds 300 lines.** Split when approaching this limit +4. **Every class has a 3-question preamble docblock:** What / Who triggers / Dependencies +5. **Every public method has a docblock** with `@param`, `@return`, and side effects +6. **`@see` references** at the top of files that participate in multi-class flows +7. **All strings translatable** via `__()` / `_e()` with `autoblogger` text domain +8. **No magic methods** without explicit justification documented in code +9. **No `echo` of raw data** — always escape with `esc_html()`, `esc_attr()`, etc. +10. **Nonce on every form/AJAX** — `wp_nonce_field()` / `check_ajax_referer()` diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100755 index 0000000..8ba8896 --- /dev/null +++ b/assets/css/admin.css @@ -0,0 +1,273 @@ +/** + * AutoBlogger admin styles — modern SaaS-inspired UI. + * + * Design language: clean cards, subtle shadows, accent color #4f46e5 (indigo), + * neutral grays from WordPress admin palette. Follows wp-admin conventions + * where practical but overrides the default "generic plugin" look. + */ + +/* ── Variables ───────────────────────────────────────────────────────── */ +:root { + --ab-primary: #4f46e5; + --ab-primary-hover: #4338ca; + --ab-primary-light: #eef2ff; + --ab-success: #16a34a; + --ab-warning: #d97706; + --ab-danger: #dc2626; + --ab-text: #1e293b; + --ab-text-muted: #64748b; + --ab-border: #e2e8f0; + --ab-bg: #f8fafc; + --ab-card-bg: #ffffff; + --ab-radius: 8px; + --ab-shadow: 0 1px 3px rgba(0,0,0,0.08); + --ab-shadow-lg: 0 4px 12px rgba(0,0,0,0.1); +} + +/* ── Wrap reset ──────────────────────────────────────────────────────── */ +.ab-wrap { max-width: 1100px; margin: 0; } +.ab-wrap h1, .ab-wrap h2, .ab-wrap h3 { margin: 0; padding: 0; } + +/* ── Header ──────────────────────────────────────────────────────────── */ +.ab-header { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 24px; margin: -8px -20px 0; background: var(--ab-card-bg); + border-bottom: 1px solid var(--ab-border); +} +.ab-header-left { display: flex; align-items: center; gap: 12px; } +.ab-header-icon { font-size: 28px; color: var(--ab-primary); width: 28px; height: 28px; } +.ab-header-title { font-size: 20px; font-weight: 700; color: var(--ab-text); line-height: 1.2; } +.ab-header-version { font-size: 11px; color: var(--ab-text-muted); background: var(--ab-bg); padding: 2px 8px; border-radius: 10px; } +.ab-header-actions { display: flex; gap: 8px; } + +/* ── Buttons ─────────────────────────────────────────────────────────── */ +.ab-btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 8px 16px; border-radius: 6px; font-size: 13px; font-weight: 600; + cursor: pointer; border: none; line-height: 1.4; text-decoration: none; + transition: all 0.15s ease; +} +.ab-btn .dashicons { font-size: 16px; width: 16px; height: 16px; line-height: 16px; } +.ab-btn-primary { background: var(--ab-primary); color: #fff; } +.ab-btn-primary:hover { background: var(--ab-primary-hover); color: #fff; } +.ab-btn-outline { background: var(--ab-card-bg); color: var(--ab-text); border: 1px solid var(--ab-border); } +.ab-btn-outline:hover { border-color: var(--ab-primary); color: var(--ab-primary); background: var(--ab-primary-light); } +.ab-btn:disabled { opacity: 0.6; cursor: not-allowed; } + +/* ── Stats bar ───────────────────────────────────────────────────────── */ +.ab-stats-bar { + display: flex; gap: 1px; margin: 20px 0 0; background: var(--ab-border); + border-radius: var(--ab-radius); overflow: hidden; box-shadow: var(--ab-shadow); +} +.ab-stat { + flex: 1; padding: 14px 20px; background: var(--ab-card-bg); + display: flex; flex-direction: column; gap: 4px; +} +.ab-stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--ab-text-muted); font-weight: 600; } +.ab-stat-value { font-size: 15px; font-weight: 700; color: var(--ab-text); } +.ab-stat-value small { font-weight: 400; color: var(--ab-text-muted); } +.ab-stat-link { color: var(--ab-primary); text-decoration: none; font-weight: 600; } +.ab-stat-link:hover { text-decoration: underline; } +.ab-text-warning { color: var(--ab-warning); } +.ab-text-danger { color: var(--ab-danger); } + +/* ── Status message ──────────────────────────────────────────────────── */ +#autoblogger-status-message { + margin: 16px 0; padding: 12px 16px; border-radius: var(--ab-radius); + border-left: 4px solid var(--ab-success); background: var(--ab-card-bg); + font-size: 13px; box-shadow: var(--ab-shadow); +} +#autoblogger-status-message.error { border-left-color: var(--ab-danger); } +#autoblogger-status-message.hidden { display: none; } + +/* ── Layout: sidebar tabs + content ──────────────────────────────────── */ +.ab-layout { + display: flex; gap: 0; margin-top: 20px; + border-radius: var(--ab-radius); overflow: hidden; + box-shadow: var(--ab-shadow-lg); background: var(--ab-card-bg); +} + +/* ── Tab navigation (vertical sidebar) ───────────────────────────────── */ +.ab-tabs { + display: flex; flex-direction: column; width: 200px; min-width: 200px; + background: var(--ab-bg); border-right: 1px solid var(--ab-border); + padding: 8px 0; +} +.ab-tab { + display: flex; align-items: center; gap: 8px; + padding: 10px 16px; color: var(--ab-text-muted); text-decoration: none; + font-size: 13px; font-weight: 500; border-left: 3px solid transparent; + transition: all 0.15s ease; +} +.ab-tab:hover { color: var(--ab-text); background: var(--ab-card-bg); } +.ab-tab-active { + color: var(--ab-primary); background: var(--ab-card-bg); + border-left-color: var(--ab-primary); font-weight: 600; +} +.ab-tab .dashicons { font-size: 16px; width: 16px; height: 16px; color: inherit; } + +/* ── Content area ────────────────────────────────────────────────────── */ +.ab-content { flex: 1; min-width: 0; } + +.ab-panel { display: none; padding: 24px 32px; } +.ab-panel-active { display: block; } + +.ab-panel-head { margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid var(--ab-border); } +.ab-panel-title { font-size: 18px; font-weight: 700; color: var(--ab-text); } +.ab-panel-desc { margin-top: 6px; font-size: 13px; color: var(--ab-text-muted); line-height: 1.5; } + +/* ── Form table override ─────────────────────────────────────────────── */ +.ab-form-table { border-collapse: separate; border-spacing: 0; } +.ab-form-table th { width: 180px; padding: 14px 16px 14px 0; vertical-align: top; font-weight: 600; font-size: 13px; color: var(--ab-text); } +.ab-form-table td { padding: 10px 0; } + +/* ── Inputs ──────────────────────────────────────────────────────────── */ +.ab-input, .ab-textarea, .ab-select { + border: 1px solid var(--ab-border); border-radius: 6px; padding: 8px 12px; + font-size: 13px; color: var(--ab-text); background: var(--ab-card-bg); + transition: border-color 0.15s ease, box-shadow 0.15s ease; + width: 100%; max-width: 420px; box-sizing: border-box; +} +.ab-input:focus, .ab-textarea:focus, .ab-select:focus { + border-color: var(--ab-primary); box-shadow: 0 0 0 3px var(--ab-primary-light); outline: none; +} +.ab-textarea { min-height: 80px; resize: vertical; } +.ab-select { appearance: auto; } +input[type="number"].ab-input { max-width: 140px; } +input[type="time"].ab-input { max-width: 160px; } + +.ab-field-desc { margin: 6px 0 0; font-size: 12px; color: var(--ab-text-muted); line-height: 1.4; } + +/* ── Badge ───────────────────────────────────────────────────────────── */ +.ab-badge { + display: inline-block; padding: 2px 8px; border-radius: 10px; + font-size: 11px; font-weight: 600; background: var(--ab-primary-light); + color: var(--ab-primary); vertical-align: middle; margin-left: 6px; +} + +/* ── Toggle switch ───────────────────────────────────────────────────── */ +.ab-toggle { position: relative; display: inline-block; width: 44px; height: 24px; cursor: pointer; } +.ab-toggle input { opacity: 0; width: 0; height: 0; } +.ab-toggle-slider { + position: absolute; top: 0; left: 0; right: 0; bottom: 0; + background: #cbd5e1; border-radius: 24px; transition: background 0.2s ease; +} +.ab-toggle-slider::before { + content: ''; position: absolute; height: 18px; width: 18px; + left: 3px; bottom: 3px; background: #fff; border-radius: 50%; + transition: transform 0.2s ease; box-shadow: 0 1px 3px rgba(0,0,0,0.2); +} +.ab-toggle input:checked + .ab-toggle-slider { background: var(--ab-primary); } +.ab-toggle input:checked + .ab-toggle-slider::before { transform: translateX(20px); } + +/* ── Checkboxes ──────────────────────────────────────────────────────── */ +.ab-checkbox-label { + display: block; padding: 6px 0; font-size: 13px; color: var(--ab-text); cursor: pointer; +} +.ab-checkbox-label input[disabled] + span, .ab-checkbox-label[disabled] { opacity: 0.5; cursor: not-allowed; } + +/* ── Save bar ────────────────────────────────────────────────────────── */ +.ab-save-bar { + padding: 16px 32px; border-top: 1px solid var(--ab-border); + background: var(--ab-bg); display: flex; justify-content: flex-end; +} +.ab-save-bar .ab-btn-save, .ab-save-bar input[type="submit"] { + padding: 10px 24px; font-size: 14px; +} + +/* ── Metrics page cards (preserved from before) ──────────────────────── */ +.autoblogger-cards { display: flex; gap: 16px; margin: 20px 0; flex-wrap: wrap; } +.autoblogger-card { background: var(--ab-card-bg); border: 1px solid var(--ab-border); border-radius: var(--ab-radius); padding: 20px; min-width: 200px; flex: 1; } +.autoblogger-card h3 { margin: 0 0 8px; font-size: 13px; color: var(--ab-text-muted); text-transform: uppercase; letter-spacing: 0.5px; } +.autoblogger-card-value { font-size: 28px; font-weight: 600; color: var(--ab-text); line-height: 1.2; } +.autoblogger-card-value-small { font-size: 18px; } +.autoblogger-card-sub { margin-top: 4px; font-size: 13px; color: var(--ab-text-muted); } +.autoblogger-card-warning { border-left: 4px solid var(--ab-warning); } +.autoblogger-card-danger { border-left: 4px solid var(--ab-danger); } +.autoblogger-budget-bar { margin-top: 12px; height: 8px; background: var(--ab-bg); border-radius: 4px; overflow: hidden; } +.autoblogger-budget-fill { height: 100%; background: var(--ab-primary); border-radius: 4px; transition: width 0.3s ease; } +.autoblogger-card-warning .autoblogger-budget-fill { background: var(--ab-warning); } +.autoblogger-card-danger .autoblogger-budget-fill { background: var(--ab-danger); } + +/* ── Metabox / verdict (shared) ──────────────────────────────────────── */ +.autoblogger-metabox p { margin: 6px 0; font-size: 12px; } +.autoblogger-verdict-approved { color: var(--ab-success); font-weight: 600; } +.autoblogger-verdict-revised { color: var(--ab-warning); font-weight: 600; } +.autoblogger-verdict-rejected { color: var(--ab-danger); font-weight: 600; } +.autoblogger-verdict-pending { color: var(--ab-text-muted); font-weight: 600; } + +/* ── Metrics tables ──────────────────────────────────────────────────── */ +.autoblogger-metrics .widefat { margin: 10px 0 30px; } + +/* ── Review queue ────────────────────────────────────────────────────── */ +.autoblogger-review-queue .autoblogger-queue-toolbar { + display: flex; align-items: center; gap: 10px; + margin: 15px 0; padding: 10px 12px; background: var(--ab-card-bg); + border: 1px solid var(--ab-border); border-radius: var(--ab-radius); +} +.autoblogger-review-queue .autoblogger-queue-count { margin-left: auto; color: var(--ab-text-muted); font-size: 13px; } +.autoblogger-review-queue .autoblogger-queue-table .check-column { width: 30px; padding: 10px 0 10px 10px; } +.autoblogger-review-queue .autoblogger-queue-actions { white-space: nowrap; } +.autoblogger-review-queue .autoblogger-queue-actions .button { margin-right: 4px; } +.autoblogger-review-queue .autoblogger-editor-notes { margin-top: 4px; font-size: 12px; color: var(--ab-text-muted); font-style: italic; } +.autoblogger-review-queue .autoblogger-empty-queue { margin: 40px 0; text-align: center; color: var(--ab-text-muted); font-size: 14px; } +.autoblogger-review-queue .autoblogger-pagination { margin: 20px 0; text-align: center; } +.autoblogger-review-queue .autoblogger-pagination .page-numbers { display: inline-block; padding: 4px 10px; margin: 0 2px; text-decoration: none; } +.autoblogger-review-queue .autoblogger-pagination .page-numbers.current { background: var(--ab-primary); color: #fff; border-radius: 3px; } + +#autoblogger-queue-status { margin: 10px 0; padding: 10px 15px; border-left: 4px solid var(--ab-success); background: var(--ab-card-bg); } +#autoblogger-queue-status.error { border-left-color: var(--ab-danger); } +#autoblogger-queue-status.hidden { display: none; } + +/* ── Log viewer ──────────────────────────────────────────────────────── */ +.ab-log-filters { display: flex; align-items: center; justify-content: space-between; margin: 16px 0; gap: 12px; flex-wrap: wrap; } +.ab-log-level-tabs { display: flex; gap: 4px; background: var(--ab-bg); border-radius: 6px; padding: 3px; } +.ab-log-level-tab { padding: 6px 14px; border-radius: 4px; font-size: 13px; font-weight: 500; color: var(--ab-text-muted); text-decoration: none; transition: all 0.15s; } +.ab-log-level-tab:hover { color: var(--ab-text); background: var(--ab-card-bg); } +.ab-log-level-active { color: var(--ab-primary); background: var(--ab-card-bg); font-weight: 600; box-shadow: var(--ab-shadow); } +.ab-log-search { display: flex; gap: 6px; } +.ab-log-search-input { max-width: 220px; padding: 6px 12px; } +.ab-btn-small { padding: 6px 12px; font-size: 12px; } +.ab-log-count { font-size: 13px; color: var(--ab-text-muted); margin: 0 0 12px; } +.ab-log-empty { text-align: center; padding: 48px 20px; color: var(--ab-text-muted); } +.ab-log-empty .dashicons { font-size: 40px; width: 40px; height: 40px; color: var(--ab-success); margin-bottom: 12px; } + +.ab-log-table { border-collapse: collapse; } +.ab-log-col-level { width: 80px; } +.ab-log-col-time { width: 160px; white-space: nowrap; } +.ab-log-col-context { width: 140px; } +.ab-log-table td { font-size: 13px; vertical-align: top; padding: 8px 10px; } +.ab-log-table th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.3px; } + +.ab-log-badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 10px; font-weight: 700; letter-spacing: 0.5px; } +.ab-log-badge-error { background: #fef2f2; color: var(--ab-danger); } +.ab-log-badge-warning { background: #fffbeb; color: var(--ab-warning); } +.ab-log-badge-info { background: var(--ab-primary-light); color: var(--ab-primary); } +.ab-log-badge-debug { background: var(--ab-bg); color: var(--ab-text-muted); } + +.ab-log-row-error { background: #fef2f2 !important; } + +.ab-log-meta-toggle { background: none; border: none; cursor: pointer; color: var(--ab-text-muted); padding: 0 4px; vertical-align: middle; } +.ab-log-meta-toggle:hover { color: var(--ab-primary); } +.ab-log-meta { display: none; margin: 8px 0 0; padding: 10px; background: var(--ab-bg); border-radius: 4px; font-size: 12px; overflow-x: auto; max-height: 200px; } + +#autoblogger-log-status { margin: 10px 0; padding: 10px 15px; border-left: 4px solid var(--ab-success); background: var(--ab-card-bg); border-radius: 4px; } +#autoblogger-log-status.error { border-left-color: var(--ab-danger); } +#autoblogger-log-status.hidden { display: none; } + +.ab-log-pagination { margin: 20px 0; text-align: center; } +.ab-log-pagination .page-numbers { display: inline-block; padding: 4px 10px; margin: 0 2px; text-decoration: none; } +.ab-log-pagination .page-numbers.current { background: var(--ab-primary); color: #fff; border-radius: 3px; } + +/* ── Responsive ──────────────────────────────────────────────────────── */ +@media screen and (max-width: 900px) { + .ab-layout { flex-direction: column; } + .ab-tabs { flex-direction: row; width: 100%; min-width: 0; overflow-x: auto; border-right: none; border-bottom: 1px solid var(--ab-border); padding: 0; } + .ab-tab { border-left: none; border-bottom: 3px solid transparent; padding: 10px 14px; white-space: nowrap; } + .ab-tab-active { border-bottom-color: var(--ab-primary); border-left-color: transparent; } + .ab-panel { padding: 20px 16px; } + .ab-header { flex-direction: column; gap: 12px; align-items: flex-start; } + .ab-stats-bar { flex-direction: column; } + .autoblogger-cards { flex-direction: column; } + .autoblogger-review-queue .autoblogger-queue-toolbar { flex-wrap: wrap; } +} diff --git a/assets/js/admin.js b/assets/js/admin.js new file mode 100755 index 0000000..93dc9e6 --- /dev/null +++ b/assets/js/admin.js @@ -0,0 +1,275 @@ +/** + * AutoBlogger admin JavaScript. + * + * Handles: tab switching, "Generate Now", "Test Connections", review queue actions. + * Uses vanilla jQuery (WordPress admin already loads it). + * + * @see admin/class-admin-page.php — Localizes autobloggerAdmin object. + */ +(function ($) { + 'use strict'; + + var config = window.autobloggerAdmin || {}; + + /* + * ── Settings page: tab switching ─────────────────────────────────── + * Switches tabs client-side without page reload. Updates URL hash + * so the browser back button works and refreshes keep the active tab. + */ + $(document).on('click', '.ab-tab', function (e) { + e.preventDefault(); + var tab = $(this).data('tab'); + if (!tab) return; + + // Switch tab nav + $('.ab-tab').removeClass('ab-tab-active').attr('aria-selected', 'false'); + $(this).addClass('ab-tab-active').attr('aria-selected', 'true'); + + // Switch panel + $('.ab-panel').removeClass('ab-panel-active'); + $('.ab-panel[data-tab="' + tab + '"]').addClass('ab-panel-active'); + + // Update URL without reload + if (window.history.replaceState) { + var url = new URL(window.location); + url.searchParams.set('tab', tab); + window.history.replaceState({}, '', url); + } + }); + + /** + * Show a status message in the admin page. + * + * @param {string} message Text to display. + * @param {string} type 'success' or 'error'. + */ + function showStatus(message, type) { + var $el = $('#autoblogger-status-message'); + $el.removeClass('hidden error') + .text(message); + if (type === 'error') { + $el.addClass('error'); + } + } + + /** + * Handle "Generate Now" button click. + */ + $(document).on('click', '#autoblogger-generate-now', function () { + var $btn = $(this); + if ($btn.prop('disabled')) return; + + $btn.prop('disabled', true).text(config.generatingText || 'Generating...'); + + $.ajax({ + url: config.ajaxUrl, + method: 'POST', + data: { + action: 'autoblogger_generate_now', + nonce: config.generateNonce + }, + timeout: 300000, // 5 minutes — generation can be slow. + success: function (response) { + if (response.success) { + var d = response.data; + showStatus( + 'Generation complete: ' + d.generated + ' generated, ' + + d.published + ' published, ' + d.rejected + ' rejected. ' + + 'Cost: $' + parseFloat(d.cost).toFixed(4), + 'success' + ); + } else { + showStatus( + 'Generation failed: ' + (response.data && response.data.message || 'Unknown error'), + 'error' + ); + } + }, + error: function (xhr, status) { + showStatus( + 'Request failed: ' + status + '. Check server error logs.', + 'error' + ); + }, + complete: function () { + $btn.prop('disabled', false).text(config.generateText || 'Generate Now'); + } + }); + }); + + /** + * Handle "Test Connections" button click. + */ + $(document).on('click', '#autoblogger-test-connection', function () { + var $btn = $(this); + if ($btn.prop('disabled')) return; + + $btn.prop('disabled', true).text(config.testingText || 'Testing...'); + + $.ajax({ + url: config.ajaxUrl, + method: 'POST', + data: { + action: 'autoblogger_test_connection', + nonce: config.testNonce, + service: 'all' + }, + timeout: 30000, + success: function (response) { + if (response.success) { + var messages = []; + var hasError = false; + $.each(response.data, function (service, result) { + messages.push(service + ': ' + result.message); + if (result.status === 'error') hasError = true; + }); + showStatus(messages.join(' | '), hasError ? 'error' : 'success'); + } else { + showStatus('Connection test failed.', 'error'); + } + }, + error: function () { + showStatus('Connection test request failed.', 'error'); + }, + complete: function () { + $btn.prop('disabled', false).text(config.testText || 'Test Connections'); + } + }); + }); + + /* + * Review Queue — inline approve/reject via AJAX. + * Removes the row on success so the user sees immediate feedback. + */ + + /** + * Show a status message on the review queue page. + * + * @param {string} message Text to display. + * @param {string} type 'success' or 'error'. + */ + function showQueueStatus(message, type) { + var $el = $('#autoblogger-queue-status'); + $el.removeClass('hidden error').text(message); + if (type === 'error') { + $el.addClass('error'); + } + } + + /** + * Handle inline "Approve" button click on review queue. + */ + $(document).on('click', '.autoblogger-approve-btn', function () { + var $btn = $(this); + var postId = $btn.data('post-id'); + if ($btn.prop('disabled')) return; + + $btn.prop('disabled', true).text('Publishing...'); + + $.ajax({ + url: config.ajaxUrl, + method: 'POST', + data: { + action: 'autoblogger_approve_post', + nonce: config.reviewNonce, + post_id: postId + }, + timeout: 15000, + success: function (response) { + if (response.success) { + $btn.closest('tr').fadeOut(300, function () { $(this).remove(); }); + showQueueStatus(response.data.message, 'success'); + } else { + showQueueStatus(response.data && response.data.message || 'Approve failed.', 'error'); + $btn.prop('disabled', false).text('Approve'); + } + }, + error: function () { + showQueueStatus('Request failed. Check server logs.', 'error'); + $btn.prop('disabled', false).text('Approve'); + } + }); + }); + + /** + * Handle inline "Reject" button click on review queue. + */ + $(document).on('click', '.autoblogger-reject-btn', function () { + var $btn = $(this); + var postId = $btn.data('post-id'); + if ($btn.prop('disabled')) return; + + if (!confirm('Reject this post? It will be moved to trash.')) return; + + $btn.prop('disabled', true).text('Rejecting...'); + + $.ajax({ + url: config.ajaxUrl, + method: 'POST', + data: { + action: 'autoblogger_reject_post', + nonce: config.reviewNonce, + post_id: postId + }, + timeout: 15000, + success: function (response) { + if (response.success) { + $btn.closest('tr').fadeOut(300, function () { $(this).remove(); }); + showQueueStatus(response.data.message, 'success'); + } else { + showQueueStatus(response.data && response.data.message || 'Reject failed.', 'error'); + $btn.prop('disabled', false).text('Reject'); + } + }, + error: function () { + showQueueStatus('Request failed. Check server logs.', 'error'); + $btn.prop('disabled', false).text('Reject'); + } + }); + }); + + /** + * Handle "Select All" checkbox on review queue. + */ + $(document).on('change', '#autoblogger-select-all', function () { + $('input[name="autoblogger_post_ids[]"]').prop('checked', this.checked); + }); + + /* + * ── Log viewer ───────────────────────────────────────────────────── + */ + + /** Toggle log entry meta detail. */ + $(document).on('click', '.ab-log-meta-toggle', function () { + $(this).next('.ab-log-meta').slideToggle(150); + $(this).find('.dashicons').toggleClass('dashicons-arrow-down-alt2 dashicons-arrow-up-alt2'); + }); + + /** Clear old logs button. */ + $(document).on('click', '#autoblogger-clear-logs', function () { + var $btn = $(this); + if ($btn.prop('disabled')) return; + if (!confirm('Delete log entries older than 30 days?')) return; + + $btn.prop('disabled', true); + var nonce = $btn.data('nonce'); + + $.ajax({ + url: config.ajaxUrl, + method: 'POST', + data: { action: 'autoblogger_clear_logs', nonce: nonce, days: 30 }, + timeout: 15000, + success: function (response) { + var $status = $('#autoblogger-log-status'); + if (response.success) { + $status.removeClass('hidden error').text(response.data.message); + setTimeout(function () { window.location.reload(); }, 1200); + } else { + $status.removeClass('hidden').addClass('error').text(response.data && response.data.message || 'Failed.'); + } + }, + complete: function () { $btn.prop('disabled', false); } + }); + }); + +})(jQuery); diff --git a/autoblogger.php b/autoblogger.php new file mode 100755 index 0000000..47146cb --- /dev/null +++ b/autoblogger.php @@ -0,0 +1,98 @@ +run(); +} ); diff --git a/composer.json b/composer.json new file mode 100755 index 0000000..7d3bafb --- /dev/null +++ b/composer.json @@ -0,0 +1,19 @@ +{ + "name": "peptiderepo/autoblogger", + "description": "WordPress plugin that auto-researches and publishes blog content using AI.", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "require": { + "php": ">=7.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "wp-phpunit/wp-phpunit": "^6.0", + "brain/monkey": "^2.6" + }, + "autoload": { + "classmap": [ + "includes/" + ] + } +} diff --git a/includes/admin/class-admin-notices.php b/includes/admin/class-admin-notices.php new file mode 100755 index 0000000..2ba1b37 --- /dev/null +++ b/includes/admin/class-admin-notices.php @@ -0,0 +1,96 @@ +check_api_key_notice(); + $this->check_budget_notice(); + $this->check_subreddit_notice(); + } + + /** + * Show a notice if the OpenRouter API key is not configured. + * + * @return void + */ + private function check_api_key_notice(): void { + $api_key = get_option( 'autoblogger_openrouter_api_key', '' ); + if ( '' !== $api_key ) { + return; + } + + printf( + '

%s %s

', + esc_html__( 'AutoBlogger: OpenRouter API key is not configured. Content generation is disabled.', 'autoblogger' ), + esc_url( admin_url( 'admin.php?page=autoblogger-settings' ) ), + esc_html__( 'Configure now', 'autoblogger' ) + ); + } + + /** + * Show a warning if monthly budget is approaching or exceeded. + * + * @return void + */ + private function check_budget_notice(): void { + $cost_tracker = new Autoblogger_Cost_Tracker(); + $utilization = $cost_tracker->get_budget_utilization(); + + if ( $utilization >= 100.0 ) { + printf( + '

%s

', + esc_html__( 'AutoBlogger: Monthly API budget EXCEEDED. Content generation is paused until next month or budget increase.', 'autoblogger' ) + ); + } elseif ( $utilization >= 80.0 ) { + printf( + '

%s

', + esc_html( sprintf( + /* translators: %s: budget utilization percentage */ + __( 'AutoBlogger: Monthly API budget is at %.0f%%. Consider increasing the budget or reducing daily article count.', 'autoblogger' ), + $utilization + ) ) + ); + } + } + + /** + * Show a notice if no subreddits are configured. + * + * @return void + */ + private function check_subreddit_notice(): void { + $subreddits = json_decode( get_option( 'autoblogger_target_subreddits', '[]' ), true ); + if ( ! empty( $subreddits ) ) { + return; + } + + // Only show on our settings page. + $screen = get_current_screen(); + if ( ! $screen || 'toplevel_page_autoblogger-settings' !== $screen->id ) { + return; + } + + printf( + '

%s

', + esc_html__( 'AutoBlogger: No subreddits configured. Add target subreddits in Source Configuration to start collecting data.', 'autoblogger' ) + ); + } +} diff --git a/includes/admin/class-admin-page.php b/includes/admin/class-admin-page.php new file mode 100755 index 0000000..84dbcb7 --- /dev/null +++ b/includes/admin/class-admin-page.php @@ -0,0 +1,207 @@ + $section ) { + add_settings_section( $section_id, $section['title'], '__return_empty_string', self::PAGE_SLUG ); + } + + foreach ( $fields as $field ) { + $section = $field['section'] ?? 'autoblogger_api'; + register_setting( self::OPTION_GROUP, $field['id'], [ + 'type' => $field['wp_type'] ?? 'string', + 'sanitize_callback' => [ $this, 'sanitize_field' ], + 'default' => $field['default'] ?? '', + ] ); + add_settings_field( $field['id'], $field['label'], [ $this, 'render_field' ], self::PAGE_SLUG, $section, $field ); + } + } + + /** Enqueue admin CSS and JS on all AutoBlogger admin pages. */ + public function on_enqueue_assets( string $hook_suffix ): void { + $pages = [ + 'toplevel_page_' . self::PAGE_SLUG, + 'autoblogger_page_autoblogger-metrics', + 'autoblogger_page_autoblogger-review-queue', + 'autoblogger_page_autoblogger-logs', + ]; + if ( ! in_array( $hook_suffix, $pages, true ) ) { + return; + } + + wp_enqueue_style( 'autoblogger-admin', AUTOBLOGGER_PLUGIN_URL . 'assets/css/admin.css', [], AUTOBLOGGER_VERSION ); + wp_enqueue_script( 'autoblogger-admin', AUTOBLOGGER_PLUGIN_URL . 'assets/js/admin.js', [ 'jquery' ], AUTOBLOGGER_VERSION, true ); + + wp_localize_script( 'autoblogger-admin', 'autobloggerAdmin', [ + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'generateNonce' => wp_create_nonce( 'autoblogger_generate_now' ), + 'testNonce' => wp_create_nonce( 'autoblogger_test_connection' ), + 'reviewNonce' => wp_create_nonce( 'autoblogger_review_queue' ), + 'generatingText' => __( 'Generating...', 'autoblogger' ), + 'generateText' => __( 'Generate Now', 'autoblogger' ), + 'testingText' => __( 'Testing...', 'autoblogger' ), + 'testText' => __( 'Test Connections', 'autoblogger' ), + ] ); + } + + /** Render the settings page (delegates to template). */ + public function render_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + $sections = Autoblogger_Settings_Fields::get_sections(); + $fields = Autoblogger_Settings_Fields::get_fields(); + include AUTOBLOGGER_PLUGIN_DIR . 'templates/admin/settings-page.php'; + } + + /** + * Render a single settings field based on its type. + * + * @param array $args Field definition. + */ + public function render_field( array $args ): void { + $id = esc_attr( $args['id'] ); + $type = $args['type'] ?? 'text'; + $default = $args['default'] ?? ''; + $desc = $args['description'] ?? ''; + $value = get_option( $args['id'], $default ); + + if ( 'password' === $type && '' !== $value ) { + $decrypted = Autoblogger_Encryption::decrypt( $value ); + $value = '' !== $decrypted ? '••••••••' : ''; + } + + switch ( $type ) { + case 'text': + case 'number': + case 'url': + case 'time': + $attrs = ''; + if ( isset( $args['min'] ) ) { $attrs .= ' min="' . esc_attr( (string) $args['min'] ) . '"'; } + if ( isset( $args['max'] ) ) { $attrs .= ' max="' . esc_attr( (string) $args['max'] ) . '"'; } + if ( isset( $args['step'] ) ) { $attrs .= ' step="' . esc_attr( (string) $args['step'] ) . '"'; } + printf( '', esc_attr( $type ), $id, $id, esc_attr( (string) $value ), $attrs ); + break; + + case 'password': + printf( '', + $id, $id, '' !== $value ? esc_attr__( 'Saved (enter new value to change)', 'autoblogger' ) : '' ); + break; + + case 'textarea': + printf( '', $id, $id, esc_textarea( (string) $value ) ); + break; + + case 'select': + printf( ''; + break; + + case 'toggle': + $checked = in_array( $value, [ '1', 'yes', true ], true ); + printf( '', + $id, $id, checked( $checked, true, false ) ); + break; + + case 'checkboxes': + $current = json_decode( (string) $value, true ) ?: []; + foreach ( ( $args['options'] ?? [] ) as $v => $label ) { + $is_checked = in_array( $v, $current, true ); + $disabled = strpos( $label, 'coming soon' ) !== false ? ' disabled' : ''; + printf( '', + $id, esc_attr( $v ), checked( $is_checked, true, false ), $disabled, esc_html( $label ) ); + } + break; + + case 'author_select': + wp_dropdown_users( [ 'name' => $id, 'id' => $id, 'selected' => absint( $value ), 'show_option_none' => __( '— Auto (first admin) —', 'autoblogger' ), 'option_none_value' => '0', 'class' => 'ab-select' ] ); + break; + + case 'category_select': + wp_dropdown_categories( [ 'name' => $id, 'id' => $id, 'selected' => absint( $value ), 'show_option_none' => __( '— Auto-assign by type —', 'autoblogger' ), 'option_none_value' => '0', 'class' => 'ab-select', 'hide_empty' => false ] ); + break; + } + + if ( isset( $args['badge'] ) ) { + printf( ' %s', esc_html( $args['badge'] ) ); + } + if ( '' !== $desc ) { + printf( '

%s

', esc_html( $desc ) ); + } + } + + /** + * Sanitize a settings field value. + * + * @param mixed $value The submitted value. + * @return mixed Sanitized value. + */ + public function sanitize_field( $value ) { + $option_name = ''; + $filter = current_filter(); + if ( 0 === strpos( $filter, 'sanitize_option_' ) ) { + $option_name = substr( $filter, strlen( 'sanitize_option_' ) ); + } + + $encrypted = [ 'autoblogger_openrouter_api_key', 'autoblogger_reddit_client_secret', 'autoblogger_ga4_credentials_json' ]; + if ( in_array( $option_name, $encrypted, true ) ) { + if ( '' === $value ) { return get_option( $option_name, '' ); } + return Autoblogger_Encryption::encrypt( sanitize_text_field( $value ) ); + } + + $json_fields = [ 'autoblogger_target_subreddits', 'autoblogger_topic_exclusions', 'autoblogger_enabled_sources' ]; + if ( in_array( $option_name, $json_fields, true ) ) { + if ( is_array( $value ) ) { return wp_json_encode( array_values( array_map( 'sanitize_text_field', $value ) ) ); } + if ( is_string( $value ) && '[' !== substr( trim( $value ), 0, 1 ) ) { + $items = array_filter( array_map( 'trim', explode( ',', $value ) ) ); + return wp_json_encode( array_values( $items ) ); + } + return sanitize_text_field( $value ); + } + + $numeric = [ 'autoblogger_daily_article_target', 'autoblogger_monthly_budget_usd', 'autoblogger_min_word_count', 'autoblogger_max_word_count', 'autoblogger_default_author', 'autoblogger_default_category' ]; + if ( in_array( $option_name, $numeric, true ) ) { + return is_numeric( $value ) ? $value : 0; + } + + return sanitize_text_field( (string) $value ); + } +} diff --git a/includes/admin/class-dashboard-widget.php b/includes/admin/class-dashboard-widget.php new file mode 100755 index 0000000..b0b075b --- /dev/null +++ b/includes/admin/class-dashboard-widget.php @@ -0,0 +1,67 @@ +get_monthly_spend(); + $budget = (float) get_option( 'autoblogger_monthly_budget_usd', 50.00 ); + $utilization = $cost_tracker->get_budget_utilization(); + + $next_run = wp_next_scheduled( 'autoblogger_daily_generation' ); + + printf( '

%s $%.2f / $%.2f (%.0f%%)

', + esc_html__( 'Monthly Spend:', 'autoblogger' ), + $monthly_spend, + $budget, + $utilization + ); + + if ( false !== $next_run ) { + printf( '

%s %s

', + esc_html__( 'Next Generation:', 'autoblogger' ), + esc_html( wp_date( 'M j, Y g:i A', $next_run ) ) + ); + } + + printf( + '

%s

', + esc_url( admin_url( 'admin.php?page=autoblogger-metrics' ) ), + esc_html__( 'View Full Metrics', 'autoblogger' ) + ); + } +} diff --git a/includes/admin/class-log-viewer.php b/includes/admin/class-log-viewer.php new file mode 100755 index 0000000..57cc1d7 --- /dev/null +++ b/includes/admin/class-log-viewer.php @@ -0,0 +1,84 @@ + __( 'Insufficient permissions.', 'autoblogger' ) ], 403 ); + return; + } + + $days = isset( $_POST['days'] ) ? absint( $_POST['days'] ) : 30; + $deleted = Autoblogger_Logger::prune( $days ); + + wp_send_json_success( [ + 'message' => sprintf( + /* translators: %d: number of entries deleted */ + __( '%d log entries cleared.', 'autoblogger' ), + $deleted + ), + ] ); + } +} diff --git a/includes/admin/class-metrics-page.php b/includes/admin/class-metrics-page.php new file mode 100755 index 0000000..d7f7e75 --- /dev/null +++ b/includes/admin/class-metrics-page.php @@ -0,0 +1,46 @@ +id ) { + return; + } + + global $post; + if ( ! $post || '1' !== get_post_meta( $post->ID, '_autoblogger_generated', true ) ) { + return; + } + + add_meta_box( + 'autoblogger_generation_info', + __( 'AutoBlogger Generation Info', 'autoblogger' ), + [ $this, 'render_metabox' ], + 'post', + 'side', + 'default' + ); + } + + /** + * Render the generation metadata metabox. + * + * @param \WP_Post $post + * + * @return void + */ + public function render_metabox( \WP_Post $post ): void { + include AUTOBLOGGER_PLUGIN_DIR . 'templates/admin/metabox-generation-info.php'; + } +} diff --git a/includes/admin/class-review-queue.php b/includes/admin/class-review-queue.php new file mode 100755 index 0000000..a8598ca --- /dev/null +++ b/includes/admin/class-review-queue.php @@ -0,0 +1,203 @@ +get_pending_posts( $paged ); + + include AUTOBLOGGER_PLUGIN_DIR . 'templates/admin/review-queue.php'; + } + + /** + * Query generated draft posts awaiting review. + * + * @param int $paged Page number. + * + * @return \WP_Query + */ + public function get_pending_posts( int $paged = 1 ): \WP_Query { + return new \WP_Query( [ + 'post_type' => 'post', + 'post_status' => 'draft', + 'posts_per_page' => 20, + 'paged' => $paged, + 'meta_query' => [ + [ + 'key' => '_autoblogger_generated', + 'value' => '1', + ], + ], + 'orderby' => 'date', + 'order' => 'DESC', + ] ); + } + + /** + * Handle bulk approve/reject actions submitted via form POST. + * + * Verifies nonce and capability before processing. Redirects back with + * a status message query arg. + * + * Side effects: changes post status, may trash posts. + * + * @return void + */ + public function on_handle_bulk_actions(): void { + if ( ! isset( $_POST['autoblogger_bulk_action'] ) ) { + return; + } + + check_admin_referer( 'autoblogger_review_queue_bulk', 'autoblogger_review_nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + $action = sanitize_text_field( wp_unslash( $_POST['autoblogger_bulk_action'] ) ); + $post_ids = isset( $_POST['autoblogger_post_ids'] ) && is_array( $_POST['autoblogger_post_ids'] ) + ? array_map( 'absint', $_POST['autoblogger_post_ids'] ) + : []; + + if ( empty( $post_ids ) ) { + return; + } + + $count = 0; + foreach ( $post_ids as $post_id ) { + if ( $post_id <= 0 ) { + continue; + } + + if ( 'approve' === $action ) { + $result = wp_update_post( [ 'ID' => $post_id, 'post_status' => 'publish' ], true ); + if ( ! is_wp_error( $result ) ) { + update_post_meta( $post_id, '_autoblogger_approved_at', gmdate( 'c' ) ); + $count++; + } + } elseif ( 'reject' === $action ) { + $result = wp_trash_post( $post_id ); + if ( false !== $result ) { + $count++; + } + } + } + + $redirect = add_query_arg( + [ + 'page' => 'autoblogger-review-queue', + 'autoblogger_bulk_done' => $count, + 'autoblogger_bulk_type' => $action, + ], + admin_url( 'admin.php' ) + ); + + wp_safe_redirect( $redirect ); + exit; + } + + /** + * AJAX handler: approve a single post (publish it). + * + * Side effects: changes post status to 'publish'. + * + * @return void + */ + public function on_ajax_approve_post(): void { + check_ajax_referer( 'autoblogger_review_queue', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'autoblogger' ) ], 403 ); + return; + } + + $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; + if ( $post_id <= 0 ) { + wp_send_json_error( [ 'message' => __( 'Invalid post ID.', 'autoblogger' ) ] ); + return; + } + + $result = wp_update_post( [ 'ID' => $post_id, 'post_status' => 'publish' ], true ); + + if ( is_wp_error( $result ) ) { + wp_send_json_error( [ 'message' => $result->get_error_message() ] ); + return; + } + + update_post_meta( $post_id, '_autoblogger_approved_at', gmdate( 'c' ) ); + wp_send_json_success( [ 'message' => __( 'Post published.', 'autoblogger' ), 'post_id' => $post_id ] ); + } + + /** + * AJAX handler: reject a single post (trash it). + * + * Side effects: trashes the post. + * + * @return void + */ + public function on_ajax_reject_post(): void { + check_ajax_referer( 'autoblogger_review_queue', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'autoblogger' ) ], 403 ); + return; + } + + $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; + if ( $post_id <= 0 ) { + wp_send_json_error( [ 'message' => __( 'Invalid post ID.', 'autoblogger' ) ] ); + return; + } + + $result = wp_trash_post( $post_id ); + if ( false === $result || null === $result ) { + wp_send_json_error( [ 'message' => __( 'Failed to reject post.', 'autoblogger' ) ] ); + return; + } + + wp_send_json_success( [ 'message' => __( 'Post rejected.', 'autoblogger' ), 'post_id' => $post_id ] ); + } +} diff --git a/includes/admin/class-settings-fields.php b/includes/admin/class-settings-fields.php new file mode 100755 index 0000000..52e86ff --- /dev/null +++ b/includes/admin/class-settings-fields.php @@ -0,0 +1,291 @@ + + */ + public static function get_sections(): array { + return [ + 'autoblogger_api' => [ + 'title' => __( 'API Keys', 'autoblogger' ), + 'icon' => 'dashicons-admin-network', + 'description' => __( 'Connect your external services. Keys are encrypted at rest.', 'autoblogger' ), + ], + 'autoblogger_models' => [ + 'title' => __( 'AI Models', 'autoblogger' ), + 'icon' => 'dashicons-superhero-alt', + 'description' => __( 'Choose which models power each stage of the pipeline.', 'autoblogger' ), + ], + 'autoblogger_content' => [ + 'title' => __( 'Content', 'autoblogger' ), + 'icon' => 'dashicons-edit-large', + 'description' => __( 'Control tone, length, pipeline mode, and topic guardrails.', 'autoblogger' ), + ], + 'autoblogger_sources' => [ + 'title' => __( 'Sources', 'autoblogger' ), + 'icon' => 'dashicons-rss', + 'description' => __( 'Configure where AutoBlogger finds trending topics.', 'autoblogger' ), + ], + 'autoblogger_schedule' => [ + 'title' => __( 'Schedule & Budget', 'autoblogger' ), + 'icon' => 'dashicons-calendar-alt', + 'description' => __( 'Set daily generation schedule, volume, and spending limits.', 'autoblogger' ), + ], + 'autoblogger_publishing' => [ + 'title' => __( 'Publishing', 'autoblogger' ), + 'icon' => 'dashicons-megaphone', + 'description' => __( 'Control how generated content is published.', 'autoblogger' ), + ], + 'autoblogger_analytics' => [ + 'title' => __( 'Analytics', 'autoblogger' ), + 'icon' => 'dashicons-chart-area', + 'description' => __( 'Connect Google Analytics 4 for post performance scoring.', 'autoblogger' ), + ], + ]; + } + + /** + * Get all settings fields. Adding a new setting = adding one entry to this array. + * + * @return array> + */ + public static function get_fields(): array { + return [ + // ── API Keys ──────────────────────────────────────────────── + [ + 'id' => 'autoblogger_openrouter_api_key', + 'label' => __( 'OpenRouter API Key', 'autoblogger' ), + 'type' => 'password', + 'section' => 'autoblogger_api', + 'description' => __( 'Get your key at openrouter.ai/keys', 'autoblogger' ), + 'icon' => '🔑', + ], + [ + 'id' => 'autoblogger_reddit_client_id', + 'label' => __( 'Reddit Client ID', 'autoblogger' ), + 'type' => 'text', + 'section' => 'autoblogger_api', + 'description' => __( 'Create a script app at reddit.com/prefs/apps', 'autoblogger' ), + 'icon' => '🤖', + ], + [ + 'id' => 'autoblogger_reddit_client_secret', + 'label' => __( 'Reddit Client Secret', 'autoblogger' ), + 'type' => 'password', + 'section' => 'autoblogger_api', + ], + + // ── AI Models ─────────────────────────────────────────────── + [ + 'id' => 'autoblogger_analysis_model', + 'label' => __( 'Analysis Model', 'autoblogger' ), + 'type' => 'text', + 'section' => 'autoblogger_models', + 'default' => AUTOBLOGGER_DEFAULT_ANALYSIS_MODEL, + 'description' => __( 'Used for source analysis. Pick a cheap, fast model.', 'autoblogger' ), + 'badge' => __( 'Low cost', 'autoblogger' ), + ], + [ + 'id' => 'autoblogger_writing_model', + 'label' => __( 'Writing Model', 'autoblogger' ), + 'type' => 'text', + 'section' => 'autoblogger_models', + 'default' => AUTOBLOGGER_DEFAULT_WRITING_MODEL, + 'description' => __( 'Used for article generation. Quality matters here.', 'autoblogger' ), + 'badge' => __( 'Quality', 'autoblogger' ), + ], + [ + 'id' => 'autoblogger_editor_model', + 'label' => __( 'Editor Model', 'autoblogger' ), + 'type' => 'text', + 'section' => 'autoblogger_models', + 'default' => AUTOBLOGGER_DEFAULT_EDITOR_MODEL, + 'description' => __( 'Used for the chief editor review pass.', 'autoblogger' ), + 'badge' => __( 'Quality', 'autoblogger' ), + ], + + // ── Content ───────────────────────────────────────────────── + [ + 'id' => 'autoblogger_niche_description', + 'label' => __( 'Niche Description', 'autoblogger' ), + 'type' => 'textarea', + 'section' => 'autoblogger_content', + 'description' => __( 'Describe your site\'s niche. Guides content analysis and generation.', 'autoblogger' ), + ], + [ + 'id' => 'autoblogger_tone', + 'label' => __( 'Content Tone', 'autoblogger' ), + 'type' => 'select', + 'section' => 'autoblogger_content', + 'default' => 'informational', + 'options' => [ + 'informational' => __( 'Informational', 'autoblogger' ), + 'conversational' => __( 'Conversational', 'autoblogger' ), + 'professional' => __( 'Professional', 'autoblogger' ), + 'casual' => __( 'Casual', 'autoblogger' ), + 'authoritative' => __( 'Authoritative', 'autoblogger' ), + ], + ], + [ + 'id' => 'autoblogger_writing_pipeline', + 'label' => __( 'Writing Pipeline', 'autoblogger' ), + 'type' => 'select', + 'section' => 'autoblogger_content', + 'default' => 'multi_step', + 'options' => [ + 'multi_step' => __( 'Multi-step (outline → draft → polish)', 'autoblogger' ), + 'single_pass' => __( 'Single-pass (one LLM call)', 'autoblogger' ), + ], + ], + [ + 'id' => 'autoblogger_min_word_count', + 'label' => __( 'Min Word Count', 'autoblogger' ), + 'type' => 'number', + 'section' => 'autoblogger_content', + 'default' => 800, + 'min' => 200, + ], + [ + 'id' => 'autoblogger_max_word_count', + 'label' => __( 'Max Word Count', 'autoblogger' ), + 'type' => 'number', + 'section' => 'autoblogger_content', + 'default' => 2000, + 'min' => 500, + ], + [ + 'id' => 'autoblogger_topic_exclusions', + 'label' => __( 'Topic Exclusions', 'autoblogger' ), + 'type' => 'textarea', + 'section' => 'autoblogger_content', + 'description' => __( 'Comma-separated topics to never write about.', 'autoblogger' ), + ], + + // ── Sources ───────────────────────────────────────────────── + [ + 'id' => 'autoblogger_target_subreddits', + 'label' => __( 'Target Subreddits', 'autoblogger' ), + 'type' => 'textarea', + 'section' => 'autoblogger_sources', + 'description' => __( 'Comma-separated, without r/. E.g.: peptides, Nootropics, biohackers', 'autoblogger' ), + ], + [ + 'id' => 'autoblogger_enabled_sources', + 'label' => __( 'Enabled Sources', 'autoblogger' ), + 'type' => 'checkboxes', + 'section' => 'autoblogger_sources', + 'options' => [ + 'reddit' => __( 'Reddit', 'autoblogger' ), + 'tiktok' => __( 'TikTok (coming soon)', 'autoblogger' ), + 'instagram' => __( 'Instagram (coming soon)', 'autoblogger' ), + 'youtube' => __( 'YouTube (coming soon)', 'autoblogger' ), + ], + 'default' => '["reddit"]', + 'description' => __( 'Select which platforms to monitor for topics.', 'autoblogger' ), + ], + + // ── Schedule & Budget ─────────────────────────────────────── + [ + 'id' => 'autoblogger_daily_article_target', + 'label' => __( 'Articles per Day', 'autoblogger' ), + 'type' => 'number', + 'section' => 'autoblogger_schedule', + 'default' => 1, + 'min' => 1, + 'max' => 10, + ], + [ + 'id' => 'autoblogger_schedule_time', + 'label' => __( 'Generation Time', 'autoblogger' ), + 'type' => 'time', + 'section' => 'autoblogger_schedule', + 'default' => '03:00', + 'description' => __( 'Daily generation runs at this time (server timezone).', 'autoblogger' ), + ], + [ + 'id' => 'autoblogger_monthly_budget_usd', + 'label' => __( 'Monthly Budget (USD)', 'autoblogger' ), + 'type' => 'number', + 'section' => 'autoblogger_schedule', + 'default' => 50, + 'min' => 0, + 'step' => '0.01', + 'description' => __( 'Hard stop when reached. 0 = unlimited.', 'autoblogger' ), + ], + + // ── Publishing ────────────────────────────────────────────── + [ + 'id' => 'autoblogger_auto_publish', + 'label' => __( 'Auto-Publish', 'autoblogger' ), + 'type' => 'toggle', + 'section' => 'autoblogger_publishing', + 'default' => '0', + 'description' => __( 'Automatically publish editor-approved posts. When off, all posts go to the Review Queue as drafts.', 'autoblogger' ), + ], + [ + 'id' => 'autoblogger_default_author', + 'label' => __( 'Default Author', 'autoblogger' ), + 'type' => 'author_select', + 'section' => 'autoblogger_publishing', + 'default' => 0, + 'description' => __( 'WordPress user assigned as author of generated posts.', 'autoblogger' ), + ], + [ + 'id' => 'autoblogger_default_category', + 'label' => __( 'Default Category', 'autoblogger' ), + 'type' => 'category_select', + 'section' => 'autoblogger_publishing', + 'default' => 0, + 'description' => __( 'Fallback category for generated posts.', 'autoblogger' ), + ], + + // ── Logging ───────────────────────────────────────────────── + [ + 'id' => 'autoblogger_log_level', + 'label' => __( 'Log Level', 'autoblogger' ), + 'type' => 'select', + 'section' => 'autoblogger_publishing', + 'default' => 'info', + 'options' => [ + 'error' => __( 'Error — only failures', 'autoblogger' ), + 'warning' => __( 'Warning — errors + warnings', 'autoblogger' ), + 'info' => __( 'Info — key events (recommended)', 'autoblogger' ), + 'debug' => __( 'Debug — everything (verbose)', 'autoblogger' ), + ], + 'description' => __( 'Controls detail level in the Activity Log.', 'autoblogger' ), + ], + + // ── Analytics ─────────────────────────────────────────────── + [ + 'id' => 'autoblogger_ga4_property_id', + 'label' => __( 'GA4 Property ID', 'autoblogger' ), + 'type' => 'text', + 'section' => 'autoblogger_analytics', + 'description' => __( 'Format: properties/XXXXXXXXX. Leave blank to skip GA4.', 'autoblogger' ), + ], + [ + 'id' => 'autoblogger_ga4_credentials_json', + 'label' => __( 'Service Account JSON', 'autoblogger' ), + 'type' => 'password', + 'section' => 'autoblogger_analytics', + 'description' => __( 'Paste the full JSON key file for a service account with Analytics read access.', 'autoblogger' ), + ], + ]; + } +} diff --git a/includes/class-activator.php b/includes/class-activator.php new file mode 100755 index 0000000..56f0166 --- /dev/null +++ b/includes/class-activator.php @@ -0,0 +1,226 @@ +get_charset_collate(); + $prefix = $wpdb->prefix . 'autoblogger_'; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + // Source data — raw posts/comments from social platforms. + $sql_source_data = "CREATE TABLE {$prefix}source_data ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + source_type VARCHAR(50) NOT NULL, + source_id VARCHAR(255) NOT NULL, + subreddit VARCHAR(255) DEFAULT NULL, + title TEXT DEFAULT NULL, + content LONGTEXT DEFAULT NULL, + author VARCHAR(255) DEFAULT NULL, + score INT DEFAULT 0, + comment_count INT DEFAULT 0, + permalink VARCHAR(500) DEFAULT NULL, + collected_at DATETIME NOT NULL, + metadata_json LONGTEXT DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY source_unique (source_type, source_id), + KEY source_collected (source_type, collected_at) + ) {$charset_collate};"; + + // Analysis results — detected patterns from source data. + $sql_analysis = "CREATE TABLE {$prefix}analysis_results ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + analysis_type VARCHAR(50) NOT NULL, + topic VARCHAR(500) NOT NULL, + summary TEXT DEFAULT NULL, + frequency INT DEFAULT 1, + relevance_score FLOAT DEFAULT 0, + source_ids_json LONGTEXT DEFAULT NULL, + analyzed_at DATETIME NOT NULL, + metadata_json LONGTEXT DEFAULT NULL, + PRIMARY KEY (id), + KEY type_relevance (analysis_type, relevance_score), + KEY analyzed_at (analyzed_at) + ) {$charset_collate};"; + + // Generation log — every API call with cost tracking. + // run_id groups all log entries from a single pipeline execution so + // link_generation_logs() can accurately attribute costs to a post. + $sql_generation_log = "CREATE TABLE {$prefix}generation_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + post_id BIGINT UNSIGNED DEFAULT NULL, + run_id VARCHAR(36) DEFAULT NULL, + stage VARCHAR(50) NOT NULL, + provider VARCHAR(50) NOT NULL, + model VARCHAR(100) NOT NULL, + prompt_tokens INT DEFAULT 0, + completion_tokens INT DEFAULT 0, + estimated_cost DECIMAL(10,6) DEFAULT 0, + request_json LONGTEXT DEFAULT NULL, + response_status VARCHAR(20) NOT NULL DEFAULT 'success', + error_message TEXT DEFAULT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY post_id (post_id), + KEY run_id (run_id), + KEY created_at (created_at), + KEY stage (stage) + ) {$charset_collate};"; + + // Content scores — post performance metrics for self-improvement. + $sql_content_scores = "CREATE TABLE {$prefix}content_scores ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + post_id BIGINT UNSIGNED NOT NULL, + pageviews INT DEFAULT 0, + avg_time_on_page FLOAT DEFAULT 0, + bounce_rate FLOAT DEFAULT 0, + comment_count INT DEFAULT 0, + composite_score FLOAT DEFAULT 0, + score_factors_json LONGTEXT DEFAULT NULL, + measured_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY post_id (post_id), + KEY measured_at (measured_at), + KEY composite_score (composite_score) + ) {$charset_collate};"; + + // Event log — structured application log entries for the admin log viewer. + $sql_event_log = "CREATE TABLE {$prefix}event_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + level VARCHAR(10) NOT NULL DEFAULT 'info', + context VARCHAR(100) DEFAULT NULL, + message TEXT NOT NULL, + meta_json LONGTEXT DEFAULT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY level (level), + KEY created_at (created_at), + KEY level_created (level, created_at) + ) {$charset_collate};"; + + dbDelta( $sql_source_data ); + dbDelta( $sql_analysis ); + dbDelta( $sql_generation_log ); + dbDelta( $sql_content_scores ); + dbDelta( $sql_event_log ); + } + + /** + * Set default option values without overwriting existing ones. + * + * Uses add_option() which is a no-op if the option already exists. + * + * Side effects: writes to wp_options. + * + * @return void + */ + private static function set_default_options(): void { + $defaults = [ + 'autoblogger_analysis_model' => AUTOBLOGGER_DEFAULT_ANALYSIS_MODEL, + 'autoblogger_writing_model' => AUTOBLOGGER_DEFAULT_WRITING_MODEL, + 'autoblogger_editor_model' => AUTOBLOGGER_DEFAULT_EDITOR_MODEL, + 'autoblogger_daily_article_target' => 1, + 'autoblogger_writing_pipeline' => 'multi_step', + 'autoblogger_niche_description' => '', + 'autoblogger_target_subreddits' => '[]', + 'autoblogger_monthly_budget_usd' => 50.00, + 'autoblogger_tone' => 'informational', + 'autoblogger_min_word_count' => 800, + 'autoblogger_max_word_count' => 2000, + 'autoblogger_topic_exclusions' => '[]', + 'autoblogger_enabled_sources' => '["reddit"]', + 'autoblogger_schedule_time' => '03:00', + 'autoblogger_log_level' => 'info', + ]; + + foreach ( $defaults as $key => $value ) { + add_option( $key, $value ); + } + } + + /** + * Schedule the daily generation cron event if not already scheduled. + * + * The custom 'autoblogger_six_hours' schedule is normally registered via + * the `cron_schedules` filter in class-autoblogger.php. However, activation + * runs before `plugins_loaded`, so the filter may not be registered yet. + * We register it inline here to ensure it exists at scheduling time. + * + * Side effects: registers a WP-Cron event, may register a cron schedule. + * + * @return void + */ + private static function schedule_cron(): void { + if ( ! wp_next_scheduled( 'autoblogger_daily_generation' ) ) { + // Schedule for the configured time tomorrow. + $time_str = get_option( 'autoblogger_schedule_time', '03:00' ); + $parts = explode( ':', $time_str ); + $hour = isset( $parts[0] ) ? absint( $parts[0] ) : 3; + $minute = isset( $parts[1] ) ? absint( $parts[1] ) : 0; + $timestamp = strtotime( "tomorrow {$hour}:{$minute}" ); + + if ( false !== $timestamp ) { + wp_schedule_event( $timestamp, 'daily', 'autoblogger_daily_generation' ); + } + } + + // Ensure the six-hour schedule exists before referencing it. + // Activation can fire before plugins_loaded, so the cron_schedules filter + // in class-autoblogger.php may not be hooked yet. + $schedules = wp_get_schedules(); + if ( ! isset( $schedules['autoblogger_six_hours'] ) ) { + add_filter( 'cron_schedules', static function ( array $scheds ): array { + $scheds['autoblogger_six_hours'] = [ + 'interval' => 6 * HOUR_IN_SECONDS, + 'display' => __( 'Every Six Hours', 'autoblogger' ), + ]; + return $scheds; + } ); + } + + // Schedule a separate metrics collection job (runs every 6 hours). + if ( ! wp_next_scheduled( 'autoblogger_collect_metrics' ) ) { + wp_schedule_event( time() + HOUR_IN_SECONDS, 'autoblogger_six_hours', 'autoblogger_collect_metrics' ); + } + } +} diff --git a/includes/class-autoblogger.php b/includes/class-autoblogger.php new file mode 100755 index 0000000..dfeca06 --- /dev/null +++ b/includes/class-autoblogger.php @@ -0,0 +1,298 @@ +initialized ) { + return; + } + $this->initialized = true; + + // Check for database migrations on admin_init. + add_action( 'admin_init', [ $this, 'on_check_db_version' ] ); + + // Register custom cron schedules. + add_filter( 'cron_schedules', [ $this, 'filter_add_cron_schedules' ] ); + + // Admin hooks — only load on admin pages. + if ( is_admin() ) { + $this->register_admin_hooks(); + } + + // Cron hooks — always registered so WP-Cron can fire them. + $this->register_cron_hooks(); + + // AJAX hooks — always registered so admin AJAX works. + $this->register_ajax_hooks(); + + /** + * Fires after AutoBlogger has finished registering all hooks. + * + * Useful for add-ons that need to hook into AutoBlogger's lifecycle. + */ + do_action( 'autoblogger_loaded' ); + } + + /** + * Register admin-only hooks (settings page, notices, metabox, dashboard widget). + * + * @return void + */ + private function register_admin_hooks(): void { + $admin_page = new Autoblogger_Admin_Page(); + add_action( 'admin_menu', [ $admin_page, 'on_register_menu' ] ); + add_action( 'admin_init', [ $admin_page, 'on_register_settings' ] ); + add_action( 'admin_enqueue_scripts', [ $admin_page, 'on_enqueue_assets' ] ); + + $notices = new Autoblogger_Admin_Notices(); + add_action( 'admin_notices', [ $notices, 'on_display_notices' ] ); + + $metabox = new Autoblogger_Post_Metabox(); + add_action( 'add_meta_boxes', [ $metabox, 'on_register_metabox' ] ); + + $metrics_page = new Autoblogger_Metrics_Page(); + add_action( 'admin_menu', [ $metrics_page, 'on_register_menu' ] ); + + $dashboard_widget = new Autoblogger_Dashboard_Widget(); + add_action( 'wp_dashboard_setup', [ $dashboard_widget, 'on_register_widget' ] ); + + $review_queue = new Autoblogger_Review_Queue(); + add_action( 'admin_menu', [ $review_queue, 'on_register_menu' ] ); + + $log_viewer = new Autoblogger_Log_Viewer(); + add_action( 'admin_menu', [ $log_viewer, 'on_register_menu' ] ); + } + + /** Register cron-triggered hooks for scheduled generation and metrics. */ + private function register_cron_hooks(): void { + add_action( 'autoblogger_daily_generation', [ $this, 'on_daily_generation' ] ); + add_action( 'autoblogger_collect_metrics', [ $this, 'on_collect_metrics' ] ); + } + + /** Register AJAX handlers for admin actions. */ + private function register_ajax_hooks(): void { + add_action( 'wp_ajax_autoblogger_generate_now', [ $this, 'on_ajax_generate_now' ] ); + add_action( 'wp_ajax_autoblogger_test_connection', [ $this, 'on_ajax_test_connection' ] ); + + $review_queue = new Autoblogger_Review_Queue(); + add_action( 'wp_ajax_autoblogger_approve_post', [ $review_queue, 'on_ajax_approve_post' ] ); + add_action( 'wp_ajax_autoblogger_reject_post', [ $review_queue, 'on_ajax_reject_post' ] ); + + $log_viewer = new Autoblogger_Log_Viewer(); + add_action( 'wp_ajax_autoblogger_clear_logs', [ $log_viewer, 'on_ajax_clear_logs' ] ); + } + + /** + * Check if database needs migration after plugin update. + * + * Compares stored DB version with current AUTOBLOGGER_DB_VERSION. + * If different, re-runs activation to apply schema changes via dbDelta. + * + * Side effects: may update database schema and db_version option. + * + * @return void + */ + public function on_check_db_version(): void { + $stored_version = get_option( 'autoblogger_db_version', '0' ); + if ( version_compare( $stored_version, AUTOBLOGGER_DB_VERSION, '<' ) ) { + Autoblogger_Activator::activate(); + } + } + + /** + * Add custom cron schedules (six-hourly for metrics collection). + * + * @param array $schedules Existing schedules. + * + * @return array Modified schedules. + */ + public function filter_add_cron_schedules( array $schedules ): array { + $schedules['autoblogger_six_hours'] = [ + 'interval' => 6 * HOUR_IN_SECONDS, + 'display' => __( 'Every Six Hours', 'autoblogger' ), + ]; + return $schedules; + } + + /** + * Handle the daily generation cron event. + * + * Uses a database-level atomic mutex to prevent overlapping runs. + * + * Side effects: API calls, database writes, WordPress post creation. + * + * @return void + */ + public function on_daily_generation(): void { + if ( ! $this->acquire_generation_lock() ) { + Autoblogger_Logger::instance()->info( 'Daily generation skipped: already running (lock held).', 'scheduler' ); + return; + } + + try { + ( new Autoblogger_Pipeline_Runner() )->run(); + } catch ( \Exception $e ) { + Autoblogger_Logger::instance()->error( 'Daily generation FAILED: ' . $e->getMessage(), 'scheduler' ); + } finally { + $this->release_generation_lock(); + } + } + + /** + * Handle the metrics collection cron event. + * + * Side effects: API calls to GA4, database writes to ab_content_scores. + * + * @return void + */ + public function on_collect_metrics(): void { + try { + $metrics = new Autoblogger_Metrics_Collector(); + $metrics->collect_all(); + } catch ( \Exception $e ) { + Autoblogger_Logger::instance()->error( 'Metrics collection FAILED: ' . $e->getMessage(), 'metrics' ); + } + } + + /** + * AJAX handler: trigger manual generation. + * + * Verifies nonce and capability, then runs the generation pipeline. + * Uses the same atomic DB lock as the cron handler. + * + * Side effects: API calls, database writes, WordPress post creation. + * + * @return void + */ + public function on_ajax_generate_now(): void { + check_ajax_referer( 'autoblogger_generate_now', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'autoblogger' ) ], 403 ); + return; + } + + if ( ! $this->acquire_generation_lock() ) { + wp_send_json_error( [ 'message' => __( 'A generation run is already in progress.', 'autoblogger' ) ] ); + return; + } + + try { + $results = ( new Autoblogger_Pipeline_Runner() )->run(); + wp_send_json_success( $results ); + } catch ( \Exception $e ) { + Autoblogger_Logger::instance()->error( 'Manual generation FAILED: ' . $e->getMessage(), 'pipeline' ); + wp_send_json_error( [ 'message' => $e->getMessage() ] ); + } finally { + $this->release_generation_lock(); + } + } + + /** AJAX handler: test API connections (OpenRouter, Reddit). */ + public function on_ajax_test_connection(): void { + check_ajax_referer( 'autoblogger_test_connection', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'autoblogger' ) ], 403 ); + return; + } + + $service = isset( $_POST['service'] ) ? sanitize_text_field( wp_unslash( $_POST['service'] ) ) : ''; + $results = []; + + if ( 'openrouter' === $service || 'all' === $service ) { + $llm = new Autoblogger_OpenRouter_Provider(); + $results['openrouter'] = $llm->validate_credentials() + ? [ 'status' => 'ok', 'message' => __( 'OpenRouter connected.', 'autoblogger' ) ] + : [ 'status' => 'error', 'message' => __( 'OpenRouter connection failed. Check your API key.', 'autoblogger' ) ]; + } + if ( 'reddit' === $service || 'all' === $service ) { + $reddit = new Autoblogger_Reddit_Provider(); + $results['reddit'] = $reddit->validate_credentials() + ? [ 'status' => 'ok', 'message' => __( 'Reddit API connected.', 'autoblogger' ) ] + : [ 'status' => 'error', 'message' => __( 'Reddit connection failed. Check client ID/secret.', 'autoblogger' ) ]; + } + wp_send_json_success( $results ); + } + + /** + * Acquire a database-level atomic mutex for generation. + * + * Uses INSERT IGNORE into wp_options — option_name has a UNIQUE index so only + * one process can insert the row. Expired locks (>1 hour) are cleaned up first. + * + * @return bool True if lock acquired, false if another process holds it. + */ + private function acquire_generation_lock(): bool { + global $wpdb; + + $lock_name = 'autoblogger_generation_lock'; + $now = (string) time(); + + // Clean up expired locks older than 1 hour — prevents permanent deadlock. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name = %s AND CAST(option_value AS UNSIGNED) < %d", + $lock_name, + time() - HOUR_IN_SECONDS + ) + ); + + // Atomic insert — UNIQUE constraint on option_name guarantees only one process succeeds. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $result = $wpdb->query( + $wpdb->prepare( + "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, %s)", + $lock_name, + $now, + 'no' + ) + ); + + return $result > 0; + } + + /** Release the generation mutex. */ + private function release_generation_lock(): void { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name = %s", + 'autoblogger_generation_lock' + ) + ); + } + +} diff --git a/includes/class-autoloader.php b/includes/class-autoloader.php new file mode 100755 index 0000000..3d8088d --- /dev/null +++ b/includes/class-autoloader.php @@ -0,0 +1,94 @@ +error( 'Encryption failed: ' . openssl_error_string(), '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 ); + } + + /** + * Decrypt a previously encrypted string. + * + * @param string $encrypted Base64-encoded ciphertext with IV prepended. + * + * @return string The original plaintext, or empty string on failure. + */ + public static function decrypt( string $encrypted ): string { + if ( '' === $encrypted ) { + return ''; + } + + $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 ); + if ( false === $raw || strlen( $raw ) <= $iv_len ) { + Autoblogger_Logger::instance()->error( 'Decryption failed: invalid ciphertext format.', 'encryption' ); + return ''; + } + + $iv = substr( $raw, 0, $iv_len ); + $ciphertext = substr( $raw, $iv_len ); + + $plaintext = openssl_decrypt( $ciphertext, self::METHOD, $key, OPENSSL_RAW_DATA, $iv ); + if ( false === $plaintext ) { + Autoblogger_Logger::instance()->error( 'Decryption failed: ' . openssl_error_string(), 'encryption' ); + return ''; + } + + return $plaintext; + } + + /** + * Derive the encryption key from WordPress salts. + * + * @return string 32-byte key suitable for AES-256. + */ + private static function get_key(): string { + return hash( 'sha256', wp_salt( 'auth' ), true ); + } +} diff --git a/includes/core/class-chief-editor.php b/includes/core/class-chief-editor.php new file mode 100755 index 0000000..505e844 --- /dev/null +++ b/includes/core/class-chief-editor.php @@ -0,0 +1,175 @@ +llm = $llm; + $this->cost_tracker = $cost_tracker; + } + + /** + * Review generated content and return an editorial verdict. + * + * The editor evaluates: content quality, factual tone, SEO elements, + * readability, and alignment with the article idea. It can approve, + * revise (and return revised content), or reject. + * + * Side effects: LLM API call(s), cost logging. + * + * @param string $content The generated HTML content. + * @param Autoblogger_Article_Idea $idea The original article idea. + * + * @return Autoblogger_Editorial_Review + */ + public function review( string $content, Autoblogger_Article_Idea $idea ): Autoblogger_Editorial_Review { + $model = get_option( 'autoblogger_editor_model', AUTOBLOGGER_DEFAULT_EDITOR_MODEL ); + $niche = get_option( 'autoblogger_niche_description', '' ); + + $system_prompt = $this->build_system_prompt( $niche ); + $user_prompt = $this->build_review_prompt( $content, $idea ); + + $response = $this->llm->send_chat_completion( + [ + [ 'role' => 'system', 'content' => $system_prompt ], + [ 'role' => 'user', 'content' => $user_prompt ], + ], + $model, + [ + 'temperature' => 0.3, + 'max_tokens' => 5000, + 'response_format' => [ 'type' => 'json_object' ], + ] + ); + + $this->cost_tracker->log_api_call( + null, + 'review', + $this->llm->get_provider_name(), + $response['model'], + $response['prompt_tokens'], + $response['completion_tokens'] + ); + + return $this->parse_review_response( $response['content'] ); + } + + /** + * @param string $niche + * + * @return string + */ + private function build_system_prompt( string $niche ): string { + $prompt = "You are a senior blog editor"; + if ( '' !== $niche ) { + $prompt .= " specializing in {$niche} content"; + } + $prompt .= ". Your job is to review article drafts before publication.\n\n"; + + $prompt .= "Evaluate the article on:\n"; + $prompt .= "1. QUALITY: Is the writing clear, engaging, and well-structured?\n"; + $prompt .= "2. ACCURACY: Does the content avoid making unsupported health/medical claims? Is the tone appropriately cautious for the topic?\n"; + $prompt .= "3. SEO: Are headings properly structured? Are target keywords naturally used?\n"; + $prompt .= "4. COMPLETENESS: Does it cover the key points? Does it have a proper intro and conclusion?\n"; + $prompt .= "5. READABILITY: Is sentence variety good? Are paragraphs appropriate length?\n\n"; + + $prompt .= "You must respond with JSON in this format:\n"; + $prompt .= "{\n"; + $prompt .= ' "verdict": "approved" | "revised" | "rejected",' . "\n"; + $prompt .= ' "quality_score": 0.0-1.0,' . "\n"; + $prompt .= ' "seo_score": 0.0-1.0,' . "\n"; + $prompt .= ' "issues": ["issue 1", "issue 2"],' . "\n"; + $prompt .= ' "notes": "Brief editorial notes",' . "\n"; + $prompt .= ' "revised_content": "Full revised HTML if verdict is revised, null otherwise"' . "\n"; + $prompt .= "}\n\n"; + + $prompt .= "Rules:\n"; + $prompt .= "- APPROVE if quality_score >= 0.7 and seo_score >= 0.6 and no critical issues\n"; + $prompt .= "- REVISE if fixable issues exist — you MUST provide the full revised HTML\n"; + $prompt .= "- REJECT only if the content is fundamentally flawed or off-topic\n"; + $prompt .= "- When revising, fix the issues yourself. Don't just describe them.\n"; + + return $prompt; + } + + /** + * @param string $content + * @param Autoblogger_Article_Idea $idea + * + * @return string + */ + private function build_review_prompt( string $content, Autoblogger_Article_Idea $idea ): string { + return sprintf( + "Review this article draft:\n\n" . + "ORIGINAL BRIEF:\n" . + "- Title: %s\n" . + "- Topic: %s\n" . + "- Type: %s\n" . + "- Key points: %s\n" . + "- Target keywords: %s\n\n" . + "ARTICLE CONTENT:\n%s", + $idea->get_suggested_title(), + $idea->get_topic(), + $idea->get_article_type(), + implode( ', ', $idea->get_key_points() ), + implode( ', ', $idea->get_target_keywords() ), + $content + ); + } + + /** + * Parse the LLM's editorial review response. + * + * @param string $content JSON response from LLM. + * + * @return Autoblogger_Editorial_Review + */ + private function parse_review_response( string $content ): Autoblogger_Editorial_Review { + $data = json_decode( $content, true ); + + if ( ! is_array( $data ) || ! isset( $data['verdict'] ) ) { + Autoblogger_Logger::instance()->error( 'Chief editor response was not valid JSON. Defaulting to rejected.', 'editor' ); + return new Autoblogger_Editorial_Review( [ + 'verdict' => 'rejected', + 'notes' => 'Editor response could not be parsed.', + 'quality_score' => 0.0, + 'seo_score' => 0.0, + 'issues' => [ 'Unparseable editor response' ], + ] ); + } + + $verdict = sanitize_text_field( $data['verdict'] ?? 'rejected' ); + if ( ! in_array( $verdict, [ 'approved', 'revised', 'rejected' ], true ) ) { + $verdict = 'rejected'; + } + + return new Autoblogger_Editorial_Review( [ + 'verdict' => $verdict, + 'notes' => sanitize_textarea_field( $data['notes'] ?? '' ), + 'revised_content' => isset( $data['revised_content'] ) ? wp_kses_post( $data['revised_content'] ) : null, + 'quality_score' => (float) ( $data['quality_score'] ?? 0.0 ), + 'seo_score' => (float) ( $data['seo_score'] ?? 0.0 ), + 'issues' => array_map( 'sanitize_text_field', $data['issues'] ?? [] ), + ] ); + } +} diff --git a/includes/core/class-content-analyzer.php b/includes/core/class-content-analyzer.php new file mode 100755 index 0000000..007310a --- /dev/null +++ b/includes/core/class-content-analyzer.php @@ -0,0 +1,293 @@ +llm = $llm; + $this->cost_tracker = $cost_tracker; + } + + /** + * Analyze recently collected source data for content patterns. + * + * Fetches unanalyzed data from the last 24 hours, batches it, sends to LLM + * for pattern detection, and stores results in ab_analysis_results. + * + * Side effects: LLM API call, database reads/writes. + * + * @return Autoblogger_Analysis_Result[] Detected patterns. + */ + public function analyze_recent_data(): array { + $source_items = $this->fetch_recent_source_data(); + + if ( empty( $source_items ) ) { + Autoblogger_Logger::instance()->info( 'No new source data to analyze.', 'analyzer' ); + return []; + } + + $niche = get_option( 'autoblogger_niche_description', '' ); + $model = get_option( 'autoblogger_analysis_model', AUTOBLOGGER_DEFAULT_ANALYSIS_MODEL ); + + // Build the source data summary for the LLM (trim to avoid huge contexts). + $summary = $this->build_source_summary( $source_items ); + + // 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 ); + + $response = $this->llm->send_chat_completion( + [ + [ 'role' => 'system', 'content' => $system_prompt ], + [ 'role' => 'user', 'content' => $user_prompt ], + ], + $model, + [ + 'temperature' => 0.3, + 'max_tokens' => 4000, + 'response_format' => [ 'type' => 'json_object' ], + ] + ); + + // Log the cost. + $this->cost_tracker->log_api_call( + null, + 'analysis', + $this->llm->get_provider_name(), + $response['model'], + $response['prompt_tokens'], + $response['completion_tokens'] + ); + + // Parse LLM response into Analysis_Result objects. + $results = $this->parse_analysis_response( $response['content'], $source_items ); + + // Store in database. + $this->store_results( $results ); + + return $results; + } + + /** + * Fetch source data collected in the last 24 hours. + * + * @return array> Raw database rows. + */ + private function fetch_recent_source_data(): array { + global $wpdb; + $table = $wpdb->prefix . 'autoblogger_source_data'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + return $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} WHERE collected_at >= %s ORDER BY score DESC LIMIT 200", + gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) + ), + ARRAY_A + ) ?: []; + } + + /** + * Build a text summary of source data for the LLM to analyze. + * + * Prioritizes high-engagement content and keeps total text under ~8000 tokens. + * + * @param array> $items Source data rows. + * + * @return string Formatted text summary. + */ + private function build_source_summary( array $items ): string { + $parts = []; + $char_limit = 30000; // Roughly ~7500 tokens. + $current_chars = 0; + + foreach ( $items as $item ) { + $entry = sprintf( + "[%s] r/%s | Score: %d | Comments: %d\nTitle: %s\n%s\n---", + $item['source_type'], + $item['subreddit'] ?? 'unknown', + (int) $item['score'], + (int) $item['comment_count'], + $item['title'] ?? '(comment)', + mb_substr( $item['content'] ?? '', 0, 500 ) + ); + + if ( $current_chars + strlen( $entry ) > $char_limit ) { + break; + } + + $parts[] = $entry; + $current_chars += strlen( $entry ); + } + + return implode( "\n", $parts ); + } + + /** + * Get past content performance data to feed into analysis for self-improvement. + * + * @return string Summary of what topics performed well/poorly. + */ + private function get_performance_context(): string { + global $wpdb; + $scores_table = $wpdb->prefix . 'autoblogger_content_scores'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $top_posts = $wpdb->get_results( + "SELECT cs.post_id, cs.composite_score, p.post_title + FROM {$scores_table} cs + JOIN {$wpdb->posts} p ON p.ID = cs.post_id + WHERE cs.composite_score > 0 + ORDER BY cs.composite_score DESC + LIMIT 5", + ARRAY_A + ); + + if ( empty( $top_posts ) ) { + return ''; + } + + $lines = [ 'Top performing past articles (learn from these):' ]; + foreach ( $top_posts as $post ) { + $lines[] = sprintf( + '- "%s" (score: %.1f)', + $post['post_title'], + (float) $post['composite_score'] + ); + } + + return implode( "\n", $lines ); + } + + /** + * Build the system prompt for content analysis. + * + * @param string $niche Niche description from settings. + * @param string $performance_context Past performance data. + * + * @return string + */ + private function build_system_prompt( string $niche, string $performance_context ): 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 .= "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 .= "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 .= "- 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"; + + if ( '' !== $performance_context ) { + $prompt .= $performance_context . "\n\n"; + } + + $prompt .= "Respond with valid JSON in this format:\n"; + $prompt .= '{"patterns": [{"type": "...", "topic": "...", "summary": "...", "frequency": N, "relevance_score": 0.X, "suggested_title": "...", "key_points": [...], "target_keywords": [...]}]}'; + + return $prompt; + } + + /** + * Build the user prompt containing the actual source data. + * + * @param string $summary Formatted source data summary. + * + * @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; + } + + /** + * Parse the LLM's JSON response into Analysis_Result objects. + * + * @param string $content LLM response content (JSON). + * @param array> $source_items Original source data for ID mapping. + * + * @return Autoblogger_Analysis_Result[] + */ + private function parse_analysis_response( string $content, array $source_items ): array { + $data = json_decode( $content, true ); + if ( ! isset( $data['patterns'] ) || ! is_array( $data['patterns'] ) ) { + Autoblogger_Logger::instance()->error( 'Analysis response was not valid JSON or missing patterns key.', 'analyzer' ); + return []; + } + + $source_ids = array_column( $source_items, 'id' ); + $results = []; + + foreach ( $data['patterns'] as $pattern ) { + $results[] = new Autoblogger_Analysis_Result( [ + 'analysis_type' => sanitize_text_field( $pattern['type'] ?? 'question' ), + 'topic' => sanitize_text_field( $pattern['topic'] ?? '' ), + 'summary' => sanitize_textarea_field( $pattern['summary'] ?? '' ), + 'frequency' => absint( $pattern['frequency'] ?? 1 ), + 'relevance_score' => (float) ( $pattern['relevance_score'] ?? 0.0 ), + 'source_ids' => $source_ids, + 'analyzed_at' => current_time( 'mysql' ), + 'metadata' => [ + 'suggested_title' => $pattern['suggested_title'] ?? '', + 'key_points' => $pattern['key_points'] ?? [], + 'target_keywords' => $pattern['target_keywords'] ?? [], + ], + ] ); + } + + return $results; + } + + /** + * Store analysis results in the database. + * + * Side effects: database inserts. + * + * @param Autoblogger_Analysis_Result[] $results + * + * @return void + */ + private function store_results( array $results ): void { + global $wpdb; + $table = $wpdb->prefix . 'autoblogger_analysis_results'; + + foreach ( $results as $result ) { + $row = $result->to_db_row(); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( $table, $row ); + } + } +} diff --git a/includes/core/class-content-generator.php b/includes/core/class-content-generator.php new file mode 100755 index 0000000..7b960ce --- /dev/null +++ b/includes/core/class-content-generator.php @@ -0,0 +1,297 @@ +llm = $llm; + $this->cost_tracker = $cost_tracker; + } + + /** + * Generate a blog post from an article idea. + * + * Reads pipeline mode from settings and dispatches to the appropriate method. + * + * Side effects: LLM API calls (1-3 depending on mode), cost logging. + * + * @param Autoblogger_Article_Idea $idea The scored idea to generate content for. + * + * @return string Generated HTML content. + * + * @throws \RuntimeException On generation failure. + */ + public function generate( Autoblogger_Article_Idea $idea ): string { + $mode = get_option( 'autoblogger_writing_pipeline', 'multi_step' ); + + $request = new Autoblogger_Content_Request( + $idea, + $mode, + get_option( 'autoblogger_tone', 'informational' ), + absint( get_option( 'autoblogger_min_word_count', 800 ) ), + absint( get_option( 'autoblogger_max_word_count', 2000 ) ), + get_option( 'autoblogger_niche_description', '' ), + json_decode( get_option( 'autoblogger_topic_exclusions', '[]' ), true ) ?: [] + ); + + if ( 'single_pass' === $mode ) { + return $this->generate_single_pass( $request ); + } + + return $this->generate_multi_step( $request ); + } + + /** + * Single-pass generation: one LLM call produces the complete article. + * + * @param Autoblogger_Content_Request $request + * + * @return string Generated HTML content. + */ + private function generate_single_pass( Autoblogger_Content_Request $request ): string { + $model = get_option( 'autoblogger_writing_model', AUTOBLOGGER_DEFAULT_WRITING_MODEL ); + $idea = $request->get_idea(); + + $system = $this->build_writer_system_prompt( $request ); + $user = $this->build_single_pass_prompt( $request ); + + $response = $this->llm->send_chat_completion( + [ + [ 'role' => 'system', 'content' => $system ], + [ 'role' => 'user', 'content' => $user ], + ], + $model, + [ + 'temperature' => 0.7, + 'max_tokens' => 4000, + ] + ); + + $this->cost_tracker->log_api_call( + null, + 'draft', + $this->llm->get_provider_name(), + $response['model'], + $response['prompt_tokens'], + $response['completion_tokens'] + ); + + return $response['content']; + } + + /** + * Multi-step generation: Outline → Draft → Polish. + * + * @param Autoblogger_Content_Request $request + * + * @return string Generated HTML content. + */ + private function generate_multi_step( Autoblogger_Content_Request $request ): string { + $model = get_option( 'autoblogger_writing_model', AUTOBLOGGER_DEFAULT_WRITING_MODEL ); + + // Step 1: Generate outline. + $outline = $this->stage_outline( $request, $model ); + + // Step 2: Generate draft from outline. + $draft = $this->stage_draft( $request, $outline, $model ); + + // Step 3: Polish the draft. + $polished = $this->stage_polish( $request, $draft, $model ); + + return $polished; + } + + /** + * Multi-step stage 1: Generate an article outline. + * + * @param Autoblogger_Content_Request $request + * @param string $model + * + * @return string The outline text. + */ + private function stage_outline( Autoblogger_Content_Request $request, string $model ): string { + $idea = $request->get_idea(); + + $prompt = sprintf( + "Create a detailed outline for a blog post titled: \"%s\"\n\n" . + "Topic: %s\nArticle type: %s\n\nKey points to cover:\n%s\n\n" . + "Target keywords: %s\n\n" . + "The outline should have 4-6 main sections with bullet points under each. " . + "Include an introduction hook and a conclusion with a call to action. " . + "Word count target: %d-%d words.", + $idea->get_suggested_title(), + $idea->get_topic(), + $idea->get_article_type(), + implode( "\n- ", $idea->get_key_points() ), + implode( ', ', $idea->get_target_keywords() ), + $request->get_min_word_count(), + $request->get_max_word_count() + ); + + $response = $this->llm->send_chat_completion( + [ + [ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ], + [ 'role' => 'user', 'content' => $prompt ], + ], + $model, + [ 'temperature' => 0.5, 'max_tokens' => 1500 ] + ); + + $this->cost_tracker->log_api_call( + null, 'outline', $this->llm->get_provider_name(), + $response['model'], $response['prompt_tokens'], $response['completion_tokens'] + ); + + return $response['content']; + } + + /** + * Multi-step stage 2: Write the full draft from the outline. + * + * @param Autoblogger_Content_Request $request + * @param string $outline + * @param string $model + * + * @return string Draft HTML content. + */ + private function stage_draft( Autoblogger_Content_Request $request, string $outline, string $model ): string { + $prompt = sprintf( + "Using this outline, write the full blog post in HTML format.\n\n" . + "OUTLINE:\n%s\n\n" . + "Requirements:\n" . + "- Write in a %s tone\n" . + "- Target %d-%d words\n" . + "- Use proper HTML headings (h2, h3), paragraphs, and lists\n" . + "- Include an engaging introduction and strong conclusion\n" . + "- Naturally incorporate these keywords: %s\n" . + "- Do NOT include the title in the HTML (it will be set separately)\n" . + "- Do NOT wrap in , , or tags — just the article content", + $outline, + $request->get_tone(), + $request->get_min_word_count(), + $request->get_max_word_count(), + implode( ', ', $request->get_idea()->get_target_keywords() ) + ); + + $response = $this->llm->send_chat_completion( + [ + [ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ], + [ 'role' => 'user', 'content' => $prompt ], + ], + $model, + [ 'temperature' => 0.7, 'max_tokens' => 4000 ] + ); + + $this->cost_tracker->log_api_call( + null, 'draft', $this->llm->get_provider_name(), + $response['model'], $response['prompt_tokens'], $response['completion_tokens'] + ); + + return $response['content']; + } + + /** + * Multi-step stage 3: Polish and refine the draft. + * + * @param Autoblogger_Content_Request $request + * @param string $draft + * @param string $model + * + * @return string Polished HTML content. + */ + private function stage_polish( Autoblogger_Content_Request $request, string $draft, string $model ): string { + $prompt = "Review and polish this blog post draft. Improve:\n" . + "1. Flow and readability\n" . + "2. SEO optimization (headings, keyword placement)\n" . + "3. Engagement (hooks, transitions, call-to-action)\n" . + "4. Accuracy and clarity\n" . + "5. Remove any filler or redundant sentences\n\n" . + "Return the polished HTML content only. Do not add commentary.\n\n" . + "DRAFT:\n" . $draft; + + $response = $this->llm->send_chat_completion( + [ + [ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ], + [ 'role' => 'user', 'content' => $prompt ], + ], + $model, + [ 'temperature' => 0.4, 'max_tokens' => 4000 ] + ); + + $this->cost_tracker->log_api_call( + null, 'polish', $this->llm->get_provider_name(), + $response['model'], $response['prompt_tokens'], $response['completion_tokens'] + ); + + return $response['content']; + } + + /** + * Build the system prompt shared across all writing stages. + * + * @param Autoblogger_Content_Request $request + * + * @return string + */ + private function build_writer_system_prompt( Autoblogger_Content_Request $request ): string { + $niche = $request->get_niche_description(); + $prompt = "You are an expert blog writer"; + if ( '' !== $niche ) { + $prompt .= " specializing in {$niche}"; + } + $prompt .= ". Write well-researched, engaging, SEO-friendly content. "; + $prompt .= "Use a {$request->get_tone()} tone. "; + $prompt .= "Output HTML content only — no markdown, no code fences, no commentary."; + return $prompt; + } + + /** + * Build the user prompt for single-pass generation. + * + * @param Autoblogger_Content_Request $request + * + * @return string + */ + private function build_single_pass_prompt( Autoblogger_Content_Request $request ): string { + $idea = $request->get_idea(); + + return sprintf( + "Write a complete blog post in HTML format.\n\n" . + "Title: %s\nTopic: %s\nType: %s\n\nKey points:\n- %s\n\n" . + "Keywords: %s\n\n" . + "Requirements:\n" . + "- %d-%d words\n" . + "- Proper HTML (h2, h3, p, ul/li)\n" . + "- Engaging intro, strong conclusion with CTA\n" . + "- Do NOT include the title or / tags\n" . + "- Output HTML only, no markdown or commentary", + $idea->get_suggested_title(), + $idea->get_topic(), + $idea->get_article_type(), + implode( "\n- ", $idea->get_key_points() ), + implode( ', ', $idea->get_target_keywords() ), + $request->get_min_word_count(), + $request->get_max_word_count() + ); + } +} diff --git a/includes/core/class-cost-tracker.php b/includes/core/class-cost-tracker.php new file mode 100755 index 0000000..cc82f71 --- /dev/null +++ b/includes/core/class-cost-tracker.php @@ -0,0 +1,254 @@ +run_id = $run_id; + } + + /** + * Get the current run_id. + * + * @return string|null + */ + public function get_run_id(): ?string { + return $this->run_id; + } + + /** + * Log an API call with its token usage and estimated cost. + * + * Side effects: database insert into ab_generation_log. + * + * @param int|null $post_id WordPress post ID (null during generation, set later). + * @param string $stage Pipeline stage: 'analysis', 'outline', 'draft', 'polish', 'review'. + * @param string $provider Provider name: 'OpenRouter'. + * @param string $model Model identifier used. + * @param int $prompt_tokens Input tokens consumed. + * @param int $completion_tokens Output tokens generated. + * @param string $response_status 'success', 'error', or 'timeout'. + * @param string $error_message Error details if status is not 'success'. + * + * @return void + */ + public function log_api_call( + ?int $post_id, + string $stage, + string $provider, + string $model, + int $prompt_tokens, + int $completion_tokens, + string $response_status = 'success', + string $error_message = '' + ): void { + $llm = new Autoblogger_OpenRouter_Provider(); + $cost = $llm->estimate_cost( $model, $prompt_tokens, $completion_tokens ); + + $this->current_run_cost += $cost; + + $log_entry = new Autoblogger_Generation_Log( [ + 'post_id' => $post_id, + 'run_id' => $this->run_id, + 'stage' => $stage, + 'provider' => $provider, + 'model' => $model, + 'prompt_tokens' => $prompt_tokens, + 'completion_tokens' => $completion_tokens, + 'estimated_cost' => $cost, + 'response_status' => $response_status, + 'error_message' => $error_message, + 'created_at' => current_time( 'mysql' ), + ] ); + + global $wpdb; + $table = $wpdb->prefix . 'autoblogger_generation_log'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( $table, $log_entry->to_db_row() ); + + if ( false === $wpdb->insert_id ) { + Autoblogger_Logger::instance()->error( 'Failed to log API call: ' . $wpdb->last_error, 'cost-tracker' ); + } + } + + /** + * Check if the monthly budget has been exceeded. + * + * Uses round() to 4 decimal places to avoid floating-point comparison edge + * cases where e.g. $49.999999997 could be treated as >= $50.00. + * + * @return bool True if monthly spend >= configured budget. + */ + public function is_budget_exceeded(): bool { + $budget = (float) get_option( 'autoblogger_monthly_budget_usd', 50.00 ); + if ( $budget <= 0 ) { + // Budget of 0 means no limit. + return false; + } + + $monthly_spend = $this->get_monthly_spend(); + + // Round to 4 decimal places (0.01 cent precision) to avoid + // floating-point representation artifacts triggering false positives. + return round( $monthly_spend, 4 ) >= round( $budget, 4 ); + } + + /** + * Get total estimated spend for the current calendar month. + * + * @return float Total USD spend this month. + */ + public function get_monthly_spend(): float { + global $wpdb; + $table = $wpdb->prefix . 'autoblogger_generation_log'; + + $first_of_month = gmdate( 'Y-m-01 00:00:00' ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $result = $wpdb->get_var( + $wpdb->prepare( + "SELECT COALESCE(SUM(estimated_cost), 0) FROM {$table} WHERE created_at >= %s AND response_status = 'success'", + $first_of_month + ) + ); + + return (float) $result; + } + + /** + * Get the cost accumulated during the current pipeline run. + * + * @return float USD cost for this run. + */ + public function get_current_run_cost(): float { + return $this->current_run_cost; + } + + /** + * Get daily spend for the last N days (for the metrics dashboard chart). + * + * @param int $days Number of days to look back. + * + * @return array Associative array of date => total_cost. + */ + public function get_daily_spend( int $days = 30 ): array { + global $wpdb; + $table = $wpdb->prefix . 'autoblogger_generation_log'; + + $start_date = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost + FROM {$table} + WHERE created_at >= %s AND response_status = 'success' + GROUP BY DATE(created_at) + ORDER BY day ASC", + $start_date . ' 00:00:00' + ), + ARRAY_A + ); + + $daily = []; + foreach ( ( $results ?: [] ) as $row ) { + $daily[ $row['day'] ] = (float) $row['total_cost']; + } + + return $daily; + } + + /** + * Get spend breakdown by pipeline stage for a given period. + * + * @param string $start_date Start date (Y-m-d). + * @param string $end_date End date (Y-m-d). + * + * @return array Breakdown by stage. + */ + public function get_spend_by_stage( string $start_date, string $end_date ): array { + global $wpdb; + $table = $wpdb->prefix . 'autoblogger_generation_log'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT stage, + SUM(estimated_cost) as total_cost, + SUM(prompt_tokens + completion_tokens) as total_tokens, + COUNT(*) as call_count + FROM {$table} + WHERE created_at BETWEEN %s AND %s AND response_status = 'success' + GROUP BY stage", + $start_date . ' 00:00:00', + $end_date . ' 23:59:59' + ), + ARRAY_A + ); + + $breakdown = []; + foreach ( ( $results ?: [] ) as $row ) { + $breakdown[ $row['stage'] ] = [ + 'cost' => (float) $row['total_cost'], + 'tokens' => (int) $row['total_tokens'], + 'calls' => (int) $row['call_count'], + ]; + } + + return $breakdown; + } + + /** + * Get budget utilization percentage for the current month. + * + * @return float Percentage (0-100+). Can exceed 100 if overspent. + */ + public function get_budget_utilization(): float { + $budget = (float) get_option( 'autoblogger_monthly_budget_usd', 50.00 ); + if ( $budget <= 0 ) { + return 0.0; + } + return ( $this->get_monthly_spend() / $budget ) * 100.0; + } +} diff --git a/includes/core/class-ga4-client.php b/includes/core/class-ga4-client.php new file mode 100755 index 0000000..e0c6de1 --- /dev/null +++ b/includes/core/class-ga4-client.php @@ -0,0 +1,202 @@ + + */ + public function fetch_data( array $posts ): array { + $property_id = get_option( 'autoblogger_ga4_property_id', '' ); + if ( '' === $property_id ) { + return []; + } + + $credentials_encrypted = get_option( 'autoblogger_ga4_credentials_json', '' ); + if ( '' === $credentials_encrypted ) { + return []; + } + + $credentials = Autoblogger_Encryption::decrypt( $credentials_encrypted ); + if ( '' === $credentials ) { + return []; + } + + // Build page paths for the GA4 query. + $page_paths = []; + $path_to_id = []; + foreach ( $posts as $post ) { + $path = wp_parse_url( get_permalink( $post->ID ), PHP_URL_PATH ); + if ( $path ) { + $page_paths[] = $path; + $path_to_id[ $path ] = (int) $post->ID; + } + } + + if ( empty( $page_paths ) ) { + return []; + } + + // GA4 Data API request. + // Using the REST API directly since we don't want a full Google SDK dependency. + $access_token = $this->get_access_token( $credentials ); + if ( '' === $access_token ) { + Autoblogger_Logger::instance()->error( 'GA4: Failed to get access token. Possible causes: invalid service account JSON, expired or revoked private key, or network error reaching oauth2.googleapis.com. Verify credentials in AutoBlogger → Settings.', 'ga4' ); + return []; + } + + $request_body = [ + 'dateRanges' => [ [ 'startDate' => '30daysAgo', 'endDate' => 'today' ] ], + 'dimensions' => [ [ 'name' => 'pagePath' ] ], + 'metrics' => [ + [ 'name' => 'screenPageViews' ], + [ 'name' => 'averageSessionDuration' ], + [ 'name' => 'bounceRate' ], + ], + 'dimensionFilter' => [ + 'filter' => [ + 'fieldName' => 'pagePath', + 'inListFilter' => [ 'values' => $page_paths ], + ], + ], + ]; + + $response = wp_remote_post( + "https://analyticsdata.googleapis.com/v1beta/properties/{$property_id}:runReport", + [ + 'timeout' => 30, + 'headers' => [ + 'Authorization' => 'Bearer ' . $access_token, + 'Content-Type' => 'application/json', + ], + 'body' => wp_json_encode( $request_body ), + ] + ); + + if ( is_wp_error( $response ) ) { + Autoblogger_Logger::instance()->error( 'GA4 API request failed (WP HTTP error): ' . $response->get_error_message(), 'ga4' ); + return []; + } + + $ga4_status = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $ga4_status ) { + $ga4_body = wp_remote_retrieve_body( $response ); + Autoblogger_Logger::instance()->error( + sprintf( 'GA4 API request failed (HTTP %d): %s', $ga4_status, substr( $ga4_body, 0, 500 ) ), + 'ga4' + ); + return []; + } + + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + $results = []; + + foreach ( ( $data['rows'] ?? [] ) as $row ) { + $path = $row['dimensionValues'][0]['value'] ?? ''; + if ( isset( $path_to_id[ $path ] ) ) { + $results[ $path_to_id[ $path ] ] = [ + 'pageviews' => (int) ( $row['metricValues'][0]['value'] ?? 0 ), + 'avg_time_on_page' => (float) ( $row['metricValues'][1]['value'] ?? 0 ), + 'bounce_rate' => (float) ( $row['metricValues'][2]['value'] ?? 0 ), + ]; + } + } + + return $results; + } + + /** + * Get a GA4 access token from service account credentials. + * + * @param string $credentials_json JSON service account key. + * + * @return string Access token, or empty string on failure. + */ + private function get_access_token( string $credentials_json ): string { + $creds = json_decode( $credentials_json, true ); + if ( ! isset( $creds['client_email'], $creds['private_key'] ) ) { + return ''; + } + + // Build JWT for service account auth. + $now = time(); + $header = wp_json_encode( [ 'alg' => 'RS256', 'typ' => 'JWT' ] ); + $claim = wp_json_encode( [ + 'iss' => $creds['client_email'], + 'scope' => 'https://www.googleapis.com/auth/analytics.readonly', + 'aud' => 'https://oauth2.googleapis.com/token', + 'exp' => $now + 3600, + 'iat' => $now, + ] ); + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + $base64_header = rtrim( strtr( base64_encode( $header ), '+/', '-_' ), '=' ); + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + $base64_claim = rtrim( strtr( base64_encode( $claim ), '+/', '-_' ), '=' ); + + $signing_input = $base64_header . '.' . $base64_claim; + $signature = ''; + + $key = openssl_pkey_get_private( $creds['private_key'] ); + if ( false === $key ) { + Autoblogger_Logger::instance()->error( 'GA4: Failed to parse private key from service account credentials. The key may be malformed or the OpenSSL extension may be misconfigured.', 'ga4' ); + return ''; + } + + openssl_sign( $signing_input, $signature, $key, OPENSSL_ALGO_SHA256 ); + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + $base64_signature = rtrim( strtr( base64_encode( $signature ), '+/', '-_' ), '=' ); + + $jwt = $signing_input . '.' . $base64_signature; + + $response = wp_remote_post( + 'https://oauth2.googleapis.com/token', + [ + 'timeout' => 15, + 'body' => [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt, + ], + ] + ); + + if ( is_wp_error( $response ) ) { + Autoblogger_Logger::instance()->error( 'GA4: OAuth token exchange failed (WP HTTP error): ' . $response->get_error_message(), 'ga4' ); + return ''; + } + + $token_status = wp_remote_retrieve_response_code( $response ); + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( 200 !== $token_status || ! isset( $data['access_token'] ) ) { + $error_desc = $data['error_description'] ?? $data['error'] ?? 'unknown'; + Autoblogger_Logger::instance()->error( + sprintf( 'GA4: OAuth token exchange failed (HTTP %d): %s', $token_status, $error_desc ), + 'ga4' + ); + return ''; + } + + return $data['access_token']; + } +} diff --git a/includes/core/class-idea-scorer.php b/includes/core/class-idea-scorer.php new file mode 100755 index 0000000..4a4ff48 --- /dev/null +++ b/includes/core/class-idea-scorer.php @@ -0,0 +1,142 @@ +is_excluded( $result->get_topic(), $exclusions ) ) { + continue; + } + + // Skip if we've already published something very similar. + if ( $this->has_similar_post( $result->get_topic() ) ) { + continue; + } + + $metadata = $result->get_metadata() ?? []; + + $ideas[] = new Autoblogger_Article_Idea( [ + 'topic' => $result->get_topic(), + 'article_type' => $this->map_type( $result->get_analysis_type() ), + 'suggested_title' => $metadata['suggested_title'] ?? $result->get_topic(), + 'summary' => $result->get_summary() ?? '', + 'score' => $this->compute_score( $result ), + 'analysis_id' => $result->get_id(), + 'source_ids' => $result->get_source_ids(), + 'key_points' => $metadata['key_points'] ?? [], + 'target_keywords' => $metadata['target_keywords'] ?? [], + ] ); + } + + // Sort by score descending. + usort( $ideas, static function ( Autoblogger_Article_Idea $a, Autoblogger_Article_Idea $b ): int { + return $b->get_score() <=> $a->get_score(); + } ); + + return array_slice( $ideas, 0, $target_count ); + } + + /** + * Compute a composite score for an analysis result. + * + * Factors: LLM relevance score (50%), frequency (30%), recency bonus (20%). + * + * @param Autoblogger_Analysis_Result $result + * + * @return float Score between 0 and 1. + */ + private function compute_score( Autoblogger_Analysis_Result $result ): float { + $relevance = min( $result->get_relevance_score(), 1.0 ); + $frequency = min( $result->get_frequency() / 10.0, 1.0 ); + + // Recency: analyzed within last 24h gets full bonus. + $hours_ago = ( time() - strtotime( $result->get_analyzed_at() ) ) / 3600.0; + $recency = max( 0.0, 1.0 - ( $hours_ago / 48.0 ) ); + + return ( $relevance * 0.5 ) + ( $frequency * 0.3 ) + ( $recency * 0.2 ); + } + + /** + * Check if a topic should be excluded based on user-configured exclusions. + * + * @param string $topic Topic text. + * @param string[] $exclusions List of excluded terms/phrases. + * + * @return bool + */ + private function is_excluded( string $topic, array $exclusions ): bool { + $topic_lower = strtolower( $topic ); + foreach ( $exclusions as $exclusion ) { + if ( false !== strpos( $topic_lower, strtolower( trim( $exclusion ) ) ) ) { + return true; + } + } + return false; + } + + /** + * Check if a substantially similar post already exists. + * + * Uses a simple keyword overlap check against existing published post titles. + * + * @param string $topic Topic to check. + * + * @return bool True if a similar post exists. + */ + private function has_similar_post( string $topic ): bool { + $query = new \WP_Query( [ + 'post_type' => 'post', + 'post_status' => [ 'publish', 'draft', 'pending' ], + 'meta_key' => '_autoblogger_generated', + 'meta_value' => '1', + 's' => $topic, + 'posts_per_page' => 1, + 'fields' => 'ids', + ] ); + + return $query->found_posts > 0; + } + + /** + * Map analysis type to a reader-friendly article type. + * + * @param string $analysis_type 'question', 'complaint', 'comparison'. + * + * @return string Article type label. + */ + private function map_type( string $analysis_type ): string { + $map = [ + 'question' => 'guide', + 'complaint' => 'solution', + 'comparison' => 'comparison', + ]; + return $map[ $analysis_type ] ?? 'article'; + } +} diff --git a/includes/core/class-logger.php b/includes/core/class-logger.php new file mode 100755 index 0000000..457729d --- /dev/null +++ b/includes/core/class-logger.php @@ -0,0 +1,220 @@ + self::LEVEL_ERROR, + 'warning' => self::LEVEL_WARNING, + 'info' => self::LEVEL_INFO, + 'debug' => self::LEVEL_DEBUG, + ]; + + private const LEVEL_LABELS = [ + self::LEVEL_ERROR => 'error', + self::LEVEL_WARNING => 'warning', + self::LEVEL_INFO => 'info', + self::LEVEL_DEBUG => 'debug', + ]; + + /** Singleton instance. */ + private static ?self $instance = null; + + /** Cached numeric threshold from settings. */ + private int $threshold; + + private function __construct() { + $setting = get_option( 'autoblogger_log_level', 'info' ); + $this->threshold = self::LEVEL_MAP[ $setting ] ?? self::LEVEL_INFO; + } + + /** Get the singleton logger instance. */ + public static function instance(): self { + if ( null === self::$instance ) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * Log an error (always captured unless logging is completely off). + * + * @param string $message Human-readable description. + * @param string $context Origin class or pipeline stage. + * @param array $meta Optional structured data. + */ + public function error( string $message, string $context = '', array $meta = [] ): void { + $this->log( self::LEVEL_ERROR, $message, $context, $meta ); + } + + /** + * Log a warning (budget approaching, retries, degraded behavior). + * + * @param string $message + * @param string $context + * @param array $meta + */ + public function warning( string $message, string $context = '', array $meta = [] ): void { + $this->log( self::LEVEL_WARNING, $message, $context, $meta ); + } + + /** + * Log an informational event (pipeline start/stop, articles generated). + * + * @param string $message + * @param string $context + * @param array $meta + */ + public function info( string $message, string $context = '', array $meta = [] ): void { + $this->log( self::LEVEL_INFO, $message, $context, $meta ); + } + + /** + * Log debug detail (API token counts, timing, intermediate values). + * + * @param string $message + * @param string $context + * @param array $meta + */ + public function debug( string $message, string $context = '', array $meta = [] ): void { + $this->log( self::LEVEL_DEBUG, $message, $context, $meta ); + } + + /** + * Core logging method. Writes to DB if level meets threshold. + * + * Side effects: database INSERT, optionally PHP error_log(). + * + * @param int $level Numeric level constant. + * @param string $message Human-readable message. + * @param string $context Origin (class/stage). + * @param array $meta Structured data. + */ + private function log( int $level, string $message, string $context, array $meta ): void { + if ( $level > $this->threshold ) { + return; + } + + $label = self::LEVEL_LABELS[ $level ] ?? 'info'; + + // Always forward errors to PHP error_log for server-level monitoring. + if ( $level <= self::LEVEL_WARNING ) { + error_log( sprintf( '[AutoBlogger][%s] %s — %s', strtoupper( $label ), $context, $message ) ); + } + + global $wpdb; + $table = $wpdb->prefix . 'autoblogger_event_log'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( $table, [ + 'level' => $label, + 'context' => substr( $context, 0, 100 ), + 'message' => substr( $message, 0, 2000 ), + 'meta_json' => ! empty( $meta ) ? wp_json_encode( $meta ) : null, + 'created_at' => current_time( 'mysql' ), + ] ); + } + + /** + * Query log entries for the admin viewer. + * + * @param string $level_filter Filter by level: 'all', 'error', 'warning', 'info', 'debug'. + * @param string $search Search within message/context. + * @param int $page Page number (1-based). + * @param int $per_page Results per page. + * + * @return array{rows: array, total: int} + */ + public static function query( + string $level_filter = 'all', + string $search = '', + int $page = 1, + int $per_page = 50 + ): array { + global $wpdb; + $table = $wpdb->prefix . 'autoblogger_event_log'; + $where = []; + $params = []; + + if ( 'all' !== $level_filter && isset( self::LEVEL_MAP[ $level_filter ] ) ) { + $where[] = 'level = %s'; + $params[] = $level_filter; + } + + if ( '' !== $search ) { + $like = '%' . $wpdb->esc_like( $search ) . '%'; + $where[] = '(message LIKE %s OR context LIKE %s)'; + $params[] = $like; + $params[] = $like; + } + + $where_sql = ! empty( $where ) ? 'WHERE ' . implode( ' AND ', $where ) : ''; + $offset = ( $page - 1 ) * $per_page; + + // Total count. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $total = (int) $wpdb->get_var( + empty( $params ) + ? "SELECT COUNT(*) FROM {$table} {$where_sql}" + : $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params ) + ); + + // Rows. + $query_params = $params; + $query_params[] = $per_page; + $query_params[] = $offset; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d", + ...$query_params + ), + ARRAY_A + ); + + return [ 'rows' => $rows ?: [], 'total' => $total ]; + } + + /** + * Prune log entries older than a given number of days. + * + * @param int $days Keep entries from the last N days. + * @return int Number of rows deleted. + */ + public static function prune( int $days = 30 ): int { + global $wpdb; + $table = $wpdb->prefix . 'autoblogger_event_log'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + return (int) $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$table} WHERE created_at < %s", + gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) ) + ) + ); + } +} diff --git a/includes/core/class-metrics-collector.php b/includes/core/class-metrics-collector.php new file mode 100755 index 0000000..7257961 --- /dev/null +++ b/includes/core/class-metrics-collector.php @@ -0,0 +1,179 @@ +get_generated_posts(); + if ( empty( $posts ) ) { + return 0; + } + + $ga4_client = new Autoblogger_GA4_Client(); + $ga4_data = $ga4_client->fetch_data( $posts ); + $scored = 0; + + foreach ( $posts as $post ) { + $post_id = (int) $post->ID; + $wp_metrics = $this->get_wp_native_metrics( $post_id ); + + // Skip posts that were deleted between the query and metrics collection. + if ( null === $wp_metrics ) { + continue; + } + + $ga_metrics = $ga4_data[ $post_id ] ?? []; + + $score = $this->compute_composite_score( $wp_metrics, $ga_metrics, $post ); + $this->store_score( $post_id, $wp_metrics, $ga_metrics, $score ); + $scored++; + } + + Autoblogger_Logger::instance()->info( sprintf( 'Metrics collected for %d posts.', $scored ), 'metrics' ); + return $scored; + } + + /** + * Get all AutoBlogger-generated published posts. + * + * @return \WP_Post[] + */ + private function get_generated_posts(): array { + $query = new \WP_Query( [ + 'post_type' => 'post', + 'post_status' => 'publish', + 'meta_key' => '_autoblogger_generated', + 'meta_value' => '1', + 'posts_per_page' => 100, + 'orderby' => 'date', + 'order' => 'DESC', + ] ); + + return $query->posts; + } + + /** + * Get WordPress native metrics for a post. + * + * @param int $post_id + * + * @return array{comment_count: int, days_published: int}|null Null if post no longer exists. + */ + private function get_wp_native_metrics( int $post_id ): ?array { + $post = get_post( $post_id ); + + // Post may have been deleted between the WP_Query and this call. + if ( null === $post ) { + Autoblogger_Logger::instance()->debug( sprintf( 'Metrics: Post ID %d no longer exists, skipping.', $post_id ), 'metrics' ); + return null; + } + + return [ + 'comment_count' => (int) ( $post->comment_count ?? 0 ), + 'days_published' => max( 1, (int) ( ( time() - strtotime( $post->post_date ) ) / DAY_IN_SECONDS ) ), + ]; + } + + + /** + * Compute a composite content score (0-100) from multiple metrics. + * + * @param array $wp_metrics WordPress native metrics. + * @param array $ga_metrics GA4 metrics (may be empty). + * @param \WP_Post $post The post object. + * + * @return array{score: float, factors: array} + */ + private function compute_composite_score( array $wp_metrics, array $ga_metrics, \WP_Post $post ): array { + $factors = []; + + // Comment engagement (0-25 points). + $comments_per_day = $wp_metrics['comment_count'] / max( 1, $wp_metrics['days_published'] ); + $factors['comments'] = min( 25.0, $comments_per_day * 25.0 ); + + // Pageview performance (0-30 points, if GA4 available). + if ( isset( $ga_metrics['pageviews'] ) ) { + $views_per_day = $ga_metrics['pageviews'] / max( 1, $wp_metrics['days_published'] ); + $factors['pageviews'] = min( 30.0, $views_per_day * 3.0 ); + } else { + $factors['pageviews'] = 0.0; + } + + // Time on page (0-25 points, if GA4 available). + if ( isset( $ga_metrics['avg_time_on_page'] ) ) { + // 3+ minutes is excellent for a blog post. + $factors['time_on_page'] = min( 25.0, ( $ga_metrics['avg_time_on_page'] / 180.0 ) * 25.0 ); + } else { + $factors['time_on_page'] = 0.0; + } + + // Low bounce rate bonus (0-20 points, if GA4 available). + if ( isset( $ga_metrics['bounce_rate'] ) ) { + // Lower bounce rate = better. 40% or below is excellent. + $factors['bounce_rate'] = max( 0.0, 20.0 - ( $ga_metrics['bounce_rate'] * 20.0 ) ); + } else { + $factors['bounce_rate'] = 0.0; + } + + $total = array_sum( $factors ); + + return [ + 'score' => round( $total, 2 ), + 'factors' => $factors, + ]; + } + + /** + * Store a content score in the database. + * + * @param int $post_id + * @param array $wp_metrics + * @param array $ga_metrics + * @param array $score_data {score: float, factors: array}. + * + * @return void + */ + private function store_score( int $post_id, array $wp_metrics, array $ga_metrics, array $score_data ): void { + global $wpdb; + $table = $wpdb->prefix . 'autoblogger_content_scores'; + + $entry = new Autoblogger_Content_Score( [ + 'post_id' => $post_id, + 'pageviews' => $ga_metrics['pageviews'] ?? 0, + 'avg_time_on_page' => $ga_metrics['avg_time_on_page'] ?? 0.0, + 'bounce_rate' => $ga_metrics['bounce_rate'] ?? 0.0, + 'comment_count' => $wp_metrics['comment_count'] ?? 0, + 'composite_score' => $score_data['score'], + 'score_factors' => $score_data['factors'], + 'measured_at' => current_time( 'mysql' ), + ] ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( $table, $entry->to_db_row() ); + } +} diff --git a/includes/core/class-pipeline-runner.php b/includes/core/class-pipeline-runner.php new file mode 100755 index 0000000..f8e7046 --- /dev/null +++ b/includes/core/class-pipeline-runner.php @@ -0,0 +1,128 @@ +set_run_id( wp_generate_uuid4() ); + + // Hard stop if monthly budget is exceeded. + if ( $cost_tracker->is_budget_exceeded() ) { + throw new \RuntimeException( + __( 'Monthly API budget exceeded. Generation halted.', 'autoblogger' ) + ); + } + + $target_count = absint( get_option( 'autoblogger_daily_article_target', 1 ) ); + + // Step 1: Collect source data. + $collector = new Autoblogger_Source_Collector(); + $collector->collect_from_all_sources(); + + // Step 2: Analyze collected data for patterns. + $llm = new Autoblogger_OpenRouter_Provider(); + $analyzer = new Autoblogger_Content_Analyzer( $llm, $cost_tracker ); + $analysis = $analyzer->analyze_recent_data(); + + // Step 3: Score and deduplicate ideas. + $scorer = new Autoblogger_Idea_Scorer(); + $ideas = $scorer->score_and_rank( $analysis, $target_count ); + + if ( empty( $ideas ) ) { + Autoblogger_Logger::instance()->info( 'No viable article ideas found. Skipping generation.', 'pipeline' ); + return [ 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ]; + } + + // Step 4 & 5: Generate content and run through editor for each idea. + $generator = new Autoblogger_Content_Generator( $llm, $cost_tracker ); + $editor = new Autoblogger_Chief_Editor( $llm, $cost_tracker ); + $publisher = new Autoblogger_Publisher(); + $auto_publish = in_array( get_option( 'autoblogger_auto_publish', '0' ), [ '1', 'yes' ], true ); + + $generated = 0; + $published = 0; + $rejected = 0; + + foreach ( $ideas as $idea ) { + // Re-check budget before each article — stop early if we hit the limit. + if ( $cost_tracker->is_budget_exceeded() ) { + Autoblogger_Logger::instance()->warning( 'Budget limit reached during generation. Stopping early.', 'pipeline' ); + break; + } + + try { + $content = $generator->generate( $idea ); + $generated++; + + $review = $editor->review( $content, $idea ); + + if ( 'approved' === $review->get_verdict() || 'revised' === $review->get_verdict() ) { + // Null-coalesce: if editor returns 'revised' verdict but + // get_revised_content() is null (e.g. malformed LLM response), + // fall back to the original generated content. + $final_content = 'revised' === $review->get_verdict() + ? ( $review->get_revised_content() ?? $content ) + : $content; + + if ( $auto_publish ) { + $publisher->publish( $final_content, $idea, $review, $cost_tracker->get_run_id() ); + $published++; + } else { + // Auto-publish disabled: send to review queue as draft. + $publisher->save_as_draft( $final_content, $idea, $review, $cost_tracker->get_run_id() ); + } + } else { + $publisher->save_as_draft( $content, $idea, $review, $cost_tracker->get_run_id() ); + $rejected++; + Autoblogger_Logger::instance()->info( 'Article rejected by editor: ' . $idea->get_topic(), 'pipeline' ); + } + } catch ( \Exception $e ) { + Autoblogger_Logger::instance()->error( 'Failed to generate article for topic "' . $idea->get_topic() . '": ' . $e->getMessage(), 'pipeline' ); + // Continue with next idea — don't let one failure stop the batch. + } + } + + $total_cost = $cost_tracker->get_current_run_cost(); + + Autoblogger_Logger::instance()->info( + sprintf( 'Pipeline complete: %d generated, %d published, %d rejected. Cost: $%.4f', $generated, $published, $rejected, $total_cost ), + 'pipeline' + ); + + return [ + 'generated' => $generated, + 'published' => $published, + 'rejected' => $rejected, + 'cost' => $total_cost, + ]; + } +} diff --git a/includes/core/class-publisher.php b/includes/core/class-publisher.php new file mode 100755 index 0000000..2eae3a9 --- /dev/null +++ b/includes/core/class-publisher.php @@ -0,0 +1,269 @@ +create_post( $content, $idea, $review, 'publish', $run_id ); + } + + /** + * Save an editor-rejected article as a draft for human review. + * + * @param string $content The generated HTML content (pre-revision). + * @param Autoblogger_Article_Idea $idea The original article idea. + * @param Autoblogger_Editorial_Review $review The editor's review with rejection notes. + * @param string|null $run_id Pipeline run ID for log linking. + * + * @return int The created post ID. + * + * @throws \RuntimeException If post creation fails. + */ + public function save_as_draft( + string $content, + Autoblogger_Article_Idea $idea, + Autoblogger_Editorial_Review $review, + ?string $run_id = null + ): int { + return $this->create_post( $content, $idea, $review, 'draft', $run_id ); + } + + /** + * Create a WordPress post with generation metadata. + * + * @param string $content HTML content. + * @param Autoblogger_Article_Idea $idea Article idea. + * @param Autoblogger_Editorial_Review $review Editorial review. + * @param string $post_status 'publish' or 'draft'. + * @param string|null $run_id Pipeline run ID for log linking. + * + * @return int Post ID. + * + * @throws \RuntimeException If wp_insert_post fails. + */ + private function create_post( + string $content, + Autoblogger_Article_Idea $idea, + Autoblogger_Editorial_Review $review, + string $post_status, + ?string $run_id = null + ): int { + $post_data = [ + 'post_title' => sanitize_text_field( $idea->get_suggested_title() ), + 'post_content' => wp_kses_post( $content ), + 'post_status' => $post_status, + 'post_type' => 'post', + 'post_author' => $this->get_default_author_id(), + 'meta_input' => $this->build_meta( $idea, $review ), + ]; + + /** + * Fires before an AutoBlogger post is created. + * + * Listeners registered in: class-autoblogger.php (main loader). + * + * @param array $post_data Post data array. + * @param Autoblogger_Article_Idea $idea The article idea. + * @param Autoblogger_Editorial_Review $review The editorial review. + */ + $post_data = apply_filters( 'autoblogger_filter_post_data', $post_data, $idea, $review ); + + $post_id = wp_insert_post( $post_data, true ); + + if ( is_wp_error( $post_id ) ) { + throw new \RuntimeException( + sprintf( + /* translators: %s: WordPress error message */ + __( 'Failed to create post: %s', 'autoblogger' ), + $post_id->get_error_message() + ) + ); + } + + // Assign categories/tags based on article type and keywords. + $this->assign_taxonomy_terms( $post_id, $idea ); + + // Update generation log entries to reference this post. + $this->link_generation_logs( $post_id, $run_id ); + + Autoblogger_Logger::instance()->info( + sprintf( 'Post created: ID=%d, status=%s, title="%s"', $post_id, $post_status, $idea->get_suggested_title() ), + 'publisher' + ); + + /** + * Fires after an AutoBlogger post is successfully created. + * + * @param int $post_id The new post ID. + * @param string $post_status 'publish' or 'draft'. + * @param Autoblogger_Article_Idea $idea The article idea. + * @param Autoblogger_Editorial_Review $review The editorial review. + */ + do_action( 'autoblogger_post_created', $post_id, $post_status, $idea, $review ); + + return $post_id; + } + + /** + * Build post_meta array for generation metadata. + * + * @param Autoblogger_Article_Idea $idea + * @param Autoblogger_Editorial_Review $review + * + * @return array + */ + private function build_meta( + Autoblogger_Article_Idea $idea, + Autoblogger_Editorial_Review $review + ): array { + $pipeline_mode = get_option( 'autoblogger_writing_pipeline', 'multi_step' ); + $model = get_option( 'autoblogger_writing_model', AUTOBLOGGER_DEFAULT_WRITING_MODEL ); + + return [ + '_autoblogger_generated' => '1', + '_autoblogger_analysis_id' => $idea->get_analysis_id(), + '_autoblogger_source_ids' => wp_json_encode( $idea->get_source_ids() ), + '_autoblogger_model_used' => $model, + '_autoblogger_pipeline_mode' => $pipeline_mode, + '_autoblogger_editor_verdict' => $review->get_verdict(), + '_autoblogger_editor_notes' => $review->get_notes(), + '_autoblogger_quality_score' => $review->get_quality_score(), + '_autoblogger_seo_score' => $review->get_seo_score(), + '_autoblogger_generated_at' => gmdate( 'c' ), + '_autoblogger_topic' => $idea->get_topic(), + '_autoblogger_article_type' => $idea->get_article_type(), + '_autoblogger_target_keywords' => wp_json_encode( $idea->get_target_keywords() ), + ]; + } + + /** + * Assign category and tags to a generated post. + * + * @param int $post_id + * @param Autoblogger_Article_Idea $idea + * + * @return void + */ + private function assign_taxonomy_terms( int $post_id, Autoblogger_Article_Idea $idea ): void { + $type_category_map = [ + 'guide' => 'Guides', + 'solution' => 'Solutions', + 'comparison' => 'Comparisons', + 'article' => 'Articles', + ]; + + $category_name = $type_category_map[ $idea->get_article_type() ] ?? 'Articles'; + + $category = get_term_by( 'name', $category_name, 'category' ); + if ( ! $category ) { + $result = wp_insert_term( $category_name, 'category' ); + if ( ! is_wp_error( $result ) ) { + wp_set_post_categories( $post_id, [ $result['term_id'] ] ); + } + } else { + wp_set_post_categories( $post_id, [ $category->term_id ] ); + } + + $keywords = $idea->get_target_keywords(); + if ( ! empty( $keywords ) ) { + wp_set_post_tags( $post_id, $keywords, true ); + } + } + + /** + * Link generation log entries to the newly created post using run_id. + * + * The previous timestamp-based approach could misattribute costs in batch runs + * where multiple articles are generated in the same pipeline execution. Using + * run_id ensures each post is linked only to the log entries from its own run. + * + * Falls back to timestamp-based linking if no run_id is set (e.g. legacy data + * from before the run_id migration). + * + * @param int $post_id The newly created post ID. + * @param string|null $run_id Pipeline run identifier, or null for legacy fallback. + * + * @return void + */ + private function link_generation_logs( int $post_id, ?string $run_id = null ): void { + global $wpdb; + $table = $wpdb->prefix . 'autoblogger_generation_log'; + + if ( null !== $run_id && '' !== $run_id ) { + // Precise linking via run_id — no risk of cross-article misattribution. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->query( + $wpdb->prepare( + "UPDATE {$table} SET post_id = %d WHERE post_id IS NULL AND run_id = %s", + $post_id, + $run_id + ) + ); + } else { + // Legacy fallback: timestamp-based linking for pre-migration log entries. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->query( + $wpdb->prepare( + "UPDATE {$table} SET post_id = %d WHERE post_id IS NULL AND created_at >= %s", + $post_id, + gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS ) + ) + ); + } + } + + /** + * Get the default author ID for generated posts. + * + * @return int WordPress user ID. + */ + private function get_default_author_id(): int { + $author_id = absint( get_option( 'autoblogger_default_author', 0 ) ); + if ( $author_id > 0 ) { + return $author_id; + } + + $admins = get_users( [ + 'role' => 'administrator', + 'number' => 1, + 'fields' => 'ID', + ] ); + + return ! empty( $admins ) ? (int) $admins[0] : 1; + } +} diff --git a/includes/core/class-source-collector.php b/includes/core/class-source-collector.php new file mode 100755 index 0000000..42151e2 --- /dev/null +++ b/includes/core/class-source-collector.php @@ -0,0 +1,167 @@ +get_provider( $source_type ); + if ( null === $provider ) { + Autoblogger_Logger::instance()->warning( "No provider found for source type: {$source_type}", 'collector' ); + continue; + } + + try { + $data = $provider->collect_data( $this->get_config_for_source( $source_type ) ); + $inserted = $this->store_data( $data ); + $total_inserted += $inserted; + + Autoblogger_Logger::instance()->info( + sprintf( 'Collected %d items from %s (%d new).', count( $data ), $source_type, $inserted ), + 'collector' + ); + } catch ( \Exception $e ) { + Autoblogger_Logger::instance()->error( "Collection from {$source_type} FAILED: " . $e->getMessage(), 'collector' ); + // Continue with other sources — one failure shouldn't stop everything. + } + } + + return $total_inserted; + } + + /** + * Store collected source data in the database. + * + * Uses INSERT IGNORE to handle duplicates gracefully (UNIQUE index). + * + * Side effects: database inserts. + * + * @param Autoblogger_Source_Data[] $items + * + * @return int Number of new records inserted. + */ + private function store_data( array $items ): int { + global $wpdb; + $table = $wpdb->prefix . 'autoblogger_source_data'; + $inserted = 0; + + foreach ( $items as $item ) { + $row = $item->to_db_row(); + + // Use INSERT IGNORE so duplicate (source_type, source_id) pairs are silently skipped. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $result = $wpdb->query( + $wpdb->prepare( + "INSERT IGNORE INTO {$table} + (source_type, source_id, subreddit, title, content, author, score, comment_count, permalink, collected_at, metadata_json) + VALUES (%s, %s, %s, %s, %s, %s, %d, %d, %s, %s, %s)", + $row['source_type'], + $row['source_id'], + $row['subreddit'], + $row['title'], + $row['content'], + $row['author'], + $row['score'], + $row['comment_count'], + $row['permalink'], + $row['collected_at'], + $row['metadata_json'] + ) + ); + + if ( false !== $result && $result > 0 ) { + $inserted++; + } + } + + return $inserted; + } + + /** + * Get the provider instance for a given source type. + * + * @param string $source_type Source type identifier. + * + * @return Autoblogger_Source_Provider_Interface|null + */ + private function get_provider( string $source_type ): ?Autoblogger_Source_Provider_Interface { + $providers = [ + 'reddit' => Autoblogger_Reddit_Provider::class, + // Future providers: 'tiktok', 'instagram', 'youtube' + ]; + + /** + * Filter the registered source providers. + * + * @param array $providers Map of source_type => class name. + */ + $providers = apply_filters( 'autoblogger_filter_source_providers', $providers ); + + if ( ! isset( $providers[ $source_type ] ) ) { + return null; + } + + $class = $providers[ $source_type ]; + + // Guard against unloadable provider classes (e.g. stub providers not yet + // autoloadable, or classes removed during development). + if ( ! class_exists( $class ) ) { + Autoblogger_Logger::instance()->error( + sprintf( 'Provider class "%s" for source type "%s" does not exist.', $class, $source_type ), + 'collector' + ); + return null; + } + + return new $class(); + } + + /** + * Build the configuration array for a specific source type from plugin settings. + * + * @param string $source_type Source type identifier. + * + * @return array Configuration for the provider. + */ + private function get_config_for_source( string $source_type ): array { + if ( 'reddit' === $source_type ) { + return [ + 'subreddits' => json_decode( get_option( 'autoblogger_target_subreddits', '[]' ), true ) ?: [], + 'limit' => 25, + 'time_filter' => 'day', + 'include_comments' => true, + 'comments_per_post' => 10, + ]; + } + + return []; + } +} diff --git a/includes/models/class-analysis-result.php b/includes/models/class-analysis-result.php new file mode 100755 index 0000000..0c05eb5 --- /dev/null +++ b/includes/models/class-analysis-result.php @@ -0,0 +1,102 @@ +id = (int) ( $data['id'] ?? 0 ); + $this->analysis_type = $data['analysis_type']; + $this->topic = $data['topic']; + $this->summary = $data['summary'] ?? null; + $this->frequency = (int) ( $data['frequency'] ?? 1 ); + $this->relevance_score = (float) ( $data['relevance_score'] ?? 0.0 ); + $this->source_ids = $data['source_ids'] ?? []; + $this->analyzed_at = $data['analyzed_at'] ?? current_time( 'mysql' ); + $this->metadata = $data['metadata'] ?? null; + } + + public function get_id(): int { + return $this->id; + } + + public function get_analysis_type(): string { + return $this->analysis_type; + } + + public function get_topic(): string { + return $this->topic; + } + + public function get_summary(): ?string { + return $this->summary; + } + + public function get_frequency(): int { + return $this->frequency; + } + + public function get_relevance_score(): float { + return $this->relevance_score; + } + + public function get_source_ids(): array { + return $this->source_ids; + } + + public function get_analyzed_at(): string { + return $this->analyzed_at; + } + + public function get_metadata(): ?array { + return $this->metadata; + } + + /** + * @return array + */ + public function to_db_row(): array { + return [ + 'analysis_type' => $this->analysis_type, + 'topic' => $this->topic, + 'summary' => $this->summary, + 'frequency' => $this->frequency, + 'relevance_score' => $this->relevance_score, + 'source_ids_json' => wp_json_encode( $this->source_ids ), + 'analyzed_at' => $this->analyzed_at, + 'metadata_json' => null !== $this->metadata ? wp_json_encode( $this->metadata ) : null, + ]; + } +} diff --git a/includes/models/class-article-idea.php b/includes/models/class-article-idea.php new file mode 100755 index 0000000..795416b --- /dev/null +++ b/includes/models/class-article-idea.php @@ -0,0 +1,87 @@ +topic = $data['topic']; + $this->article_type = $data['article_type']; + $this->suggested_title = $data['suggested_title']; + $this->summary = $data['summary']; + $this->score = (float) ( $data['score'] ?? 0.0 ); + $this->analysis_id = (int) ( $data['analysis_id'] ?? 0 ); + $this->source_ids = $data['source_ids'] ?? []; + $this->key_points = $data['key_points'] ?? []; + $this->target_keywords = $data['target_keywords'] ?? []; + } + + public function get_topic(): string { + return $this->topic; + } + + public function get_article_type(): string { + return $this->article_type; + } + + public function get_suggested_title(): string { + return $this->suggested_title; + } + + public function get_summary(): string { + return $this->summary; + } + + public function get_score(): float { + return $this->score; + } + + public function get_analysis_id(): int { + return $this->analysis_id; + } + + public function get_source_ids(): array { + return $this->source_ids; + } + + public function get_key_points(): array { + return $this->key_points; + } + + public function get_target_keywords(): array { + return $this->target_keywords; + } +} diff --git a/includes/models/class-content-request.php b/includes/models/class-content-request.php new file mode 100755 index 0000000..c00e77c --- /dev/null +++ b/includes/models/class-content-request.php @@ -0,0 +1,70 @@ +idea = $idea; + $this->pipeline_mode = $pipeline_mode; + $this->tone = $tone; + $this->min_word_count = $min_word_count; + $this->max_word_count = $max_word_count; + $this->niche_description = $niche_description; + $this->topic_exclusions = $topic_exclusions; + } + + public function get_idea(): Autoblogger_Article_Idea { + return $this->idea; + } + + public function get_pipeline_mode(): string { + return $this->pipeline_mode; + } + + public function get_tone(): string { + return $this->tone; + } + + public function get_min_word_count(): int { + return $this->min_word_count; + } + + public function get_max_word_count(): int { + return $this->max_word_count; + } + + public function get_niche_description(): string { + return $this->niche_description; + } + + public function get_topic_exclusions(): array { + return $this->topic_exclusions; + } +} diff --git a/includes/models/class-content-score.php b/includes/models/class-content-score.php new file mode 100755 index 0000000..387e574 --- /dev/null +++ b/includes/models/class-content-score.php @@ -0,0 +1,65 @@ + $data + */ + public function __construct( array $data ) { + $this->id = (int) ( $data['id'] ?? 0 ); + $this->post_id = (int) ( $data['post_id'] ?? 0 ); + $this->pageviews = (int) ( $data['pageviews'] ?? 0 ); + $this->avg_time_on_page = (float) ( $data['avg_time_on_page'] ?? 0.0 ); + $this->bounce_rate = (float) ( $data['bounce_rate'] ?? 0.0 ); + $this->comment_count = (int) ( $data['comment_count'] ?? 0 ); + $this->composite_score = (float) ( $data['composite_score'] ?? 0.0 ); + $this->score_factors = $data['score_factors'] ?? null; + $this->measured_at = $data['measured_at'] ?? current_time( 'mysql' ); + } + + public function get_id(): int { return $this->id; } + public function get_post_id(): int { return $this->post_id; } + public function get_pageviews(): int { return $this->pageviews; } + public function get_avg_time_on_page(): float { return $this->avg_time_on_page; } + public function get_bounce_rate(): float { return $this->bounce_rate; } + public function get_comment_count(): int { return $this->comment_count; } + public function get_composite_score(): float { return $this->composite_score; } + public function get_score_factors(): ?array { return $this->score_factors; } + public function get_measured_at(): string { return $this->measured_at; } + + /** + * @return array + */ + public function to_db_row(): array { + return [ + 'post_id' => $this->post_id, + 'pageviews' => $this->pageviews, + 'avg_time_on_page' => $this->avg_time_on_page, + 'bounce_rate' => $this->bounce_rate, + 'comment_count' => $this->comment_count, + 'composite_score' => $this->composite_score, + 'score_factors_json' => null !== $this->score_factors ? wp_json_encode( $this->score_factors ) : null, + 'measured_at' => $this->measured_at, + ]; + } +} diff --git a/includes/models/class-editorial-review.php b/includes/models/class-editorial-review.php new file mode 100755 index 0000000..b3929da --- /dev/null +++ b/includes/models/class-editorial-review.php @@ -0,0 +1,64 @@ +verdict = $data['verdict']; // 'approved', 'revised', 'rejected' + $this->notes = $data['notes']; + $this->revised_content = $data['revised_content'] ?? null; + $this->quality_score = (float) ( $data['quality_score'] ?? 0.0 ); + $this->seo_score = (float) ( $data['seo_score'] ?? 0.0 ); + $this->issues = $data['issues'] ?? []; + } + + public function get_verdict(): string { + return $this->verdict; + } + + public function get_notes(): string { + return $this->notes; + } + + public function get_revised_content(): ?string { + return $this->revised_content; + } + + public function get_quality_score(): float { + return $this->quality_score; + } + + public function get_seo_score(): float { + return $this->seo_score; + } + + public function get_issues(): array { + return $this->issues; + } +} diff --git a/includes/models/class-generation-log.php b/includes/models/class-generation-log.php new file mode 100755 index 0000000..83e5338 --- /dev/null +++ b/includes/models/class-generation-log.php @@ -0,0 +1,81 @@ + $data + */ + public function __construct( array $data ) { + $this->id = (int) ( $data['id'] ?? 0 ); + $this->post_id = isset( $data['post_id'] ) ? (int) $data['post_id'] : null; + $this->run_id = $data['run_id'] ?? null; + $this->stage = $data['stage'] ?? ''; + $this->provider = $data['provider'] ?? ''; + $this->model = $data['model'] ?? ''; + $this->prompt_tokens = (int) ( $data['prompt_tokens'] ?? 0 ); + $this->completion_tokens = (int) ( $data['completion_tokens'] ?? 0 ); + $this->estimated_cost = (float) ( $data['estimated_cost'] ?? 0.0 ); + $this->request_json = $data['request_json'] ?? null; + $this->response_status = $data['response_status'] ?? 'success'; + $this->error_message = $data['error_message'] ?? null; + $this->created_at = $data['created_at'] ?? current_time( 'mysql' ); + } + + public function get_id(): int { return $this->id; } + public function get_post_id(): ?int { return $this->post_id; } + public function get_run_id(): ?string { return $this->run_id; } + public function get_stage(): string { return $this->stage; } + public function get_provider(): string { return $this->provider; } + public function get_model(): string { return $this->model; } + public function get_prompt_tokens(): int { return $this->prompt_tokens; } + public function get_completion_tokens(): int { return $this->completion_tokens; } + public function get_estimated_cost(): float { return $this->estimated_cost; } + public function get_request_json(): ?string { return $this->request_json; } + public function get_response_status(): string { return $this->response_status; } + public function get_error_message(): ?string { return $this->error_message; } + public function get_created_at(): string { return $this->created_at; } + + /** + * @return array + */ + public function to_db_row(): array { + return [ + 'post_id' => $this->post_id, + 'run_id' => $this->run_id, + 'stage' => $this->stage, + 'provider' => $this->provider, + 'model' => $this->model, + 'prompt_tokens' => $this->prompt_tokens, + 'completion_tokens' => $this->completion_tokens, + 'estimated_cost' => $this->estimated_cost, + 'request_json' => $this->request_json, + 'response_status' => $this->response_status, + 'error_message' => $this->error_message, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/includes/models/class-source-data.php b/includes/models/class-source-data.php new file mode 100755 index 0000000..654a93d --- /dev/null +++ b/includes/models/class-source-data.php @@ -0,0 +1,128 @@ +id = (int) ( $data['id'] ?? 0 ); + $this->source_type = $data['source_type']; + $this->source_id = $data['source_id']; + $this->subreddit = $data['subreddit'] ?? null; + $this->title = $data['title'] ?? null; + $this->content = $data['content'] ?? null; + $this->author = $data['author'] ?? null; + $this->score = (int) ( $data['score'] ?? 0 ); + $this->comment_count = (int) ( $data['comment_count'] ?? 0 ); + $this->permalink = $data['permalink'] ?? null; + $this->collected_at = $data['collected_at'] ?? current_time( 'mysql' ); + $this->metadata = $data['metadata'] ?? null; + } + + public function get_id(): int { + return $this->id; + } + + public function get_source_type(): string { + return $this->source_type; + } + + public function get_source_id(): string { + return $this->source_id; + } + + public function get_subreddit(): ?string { + return $this->subreddit; + } + + public function get_title(): ?string { + return $this->title; + } + + public function get_content(): ?string { + return $this->content; + } + + public function get_author(): ?string { + return $this->author; + } + + public function get_score(): int { + return $this->score; + } + + public function get_comment_count(): int { + return $this->comment_count; + } + + public function get_permalink(): ?string { + return $this->permalink; + } + + public function get_collected_at(): string { + return $this->collected_at; + } + + public function get_metadata(): ?array { + return $this->metadata; + } + + /** + * Convert to array for database insertion. + * + * @return array + */ + public function to_db_row(): array { + return [ + 'source_type' => $this->source_type, + 'source_id' => $this->source_id, + 'subreddit' => $this->subreddit, + 'title' => $this->title, + 'content' => $this->content, + 'author' => $this->author, + 'score' => $this->score, + 'comment_count' => $this->comment_count, + 'permalink' => $this->permalink, + 'collected_at' => $this->collected_at, + 'metadata_json' => null !== $this->metadata ? wp_json_encode( $this->metadata ) : null, + ]; + } +} diff --git a/includes/providers/class-instagram-provider.php b/includes/providers/class-instagram-provider.php new file mode 100755 index 0000000..0b3f83b --- /dev/null +++ b/includes/providers/class-instagram-provider.php @@ -0,0 +1,39 @@ + $config + * @return Autoblogger_Source_Data[] + * @throws \RuntimeException Always — not yet implemented. + */ + public function collect_data( array $config ): array { + throw new \RuntimeException( + __( 'Instagram source provider is not yet implemented. Remove "instagram" from enabled sources.', 'autoblogger' ) + ); + } + + public function get_source_type(): string { + return 'instagram'; + } + + public function validate_credentials(): bool { + return false; + } + + public function get_rate_limit_status(): array { + return [ 'remaining' => 0, 'limit' => 0, 'resets_at' => '' ]; + } +} diff --git a/includes/providers/class-openrouter-pricing.php b/includes/providers/class-openrouter-pricing.php new file mode 100755 index 0000000..aeddc35 --- /dev/null +++ b/includes/providers/class-openrouter-pricing.php @@ -0,0 +1,137 @@ + + */ + private const MODEL_PRICING = [ + 'anthropic/claude-3.5-haiku' => [ 'prompt' => 0.80, 'completion' => 4.00 ], + 'anthropic/claude-sonnet-4' => [ 'prompt' => 3.00, 'completion' => 15.00 ], + 'anthropic/claude-opus-4' => [ 'prompt' => 15.00, 'completion' => 75.00 ], + 'openai/gpt-4o' => [ 'prompt' => 2.50, 'completion' => 10.00 ], + 'openai/gpt-4o-mini' => [ 'prompt' => 0.15, 'completion' => 0.60 ], + 'google/gemini-2.0-flash' => [ 'prompt' => 0.10, 'completion' => 0.40 ], + 'meta-llama/llama-3.3-70b' => [ 'prompt' => 0.40, 'completion' => 0.40 ], + ]; + + /** + * Get available models from OpenRouter's /models endpoint. + * + * Caches the result in a transient for 24 hours to avoid repeated API calls. + * + * Side effects: HTTP request (if cache miss), sets transient. + * + * @return array + */ + public function get_available_models(): array { + $cached = get_transient( 'autoblogger_openrouter_models' ); + if ( false !== $cached && is_array( $cached ) ) { + return $cached; + } + + $api_key = $this->get_api_key(); + if ( '' === $api_key ) { + return []; + } + + $response = wp_remote_get( + 'https://openrouter.ai/api/v1/models', + [ + 'timeout' => 30, + 'headers' => [ + 'Authorization' => 'Bearer ' . $api_key, + ], + ] + ); + + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + Autoblogger_Logger::instance()->warning( 'Failed to fetch OpenRouter models list.', 'openrouter' ); + return []; + } + + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + $models = []; + + if ( isset( $data['data'] ) && is_array( $data['data'] ) ) { + foreach ( $data['data'] as $model ) { + $models[] = [ + 'id' => $model['id'] ?? '', + 'name' => $model['name'] ?? $model['id'] ?? '', + 'context_length' => (int) ( $model['context_length'] ?? 0 ), + 'pricing' => [ + 'prompt' => (float) ( $model['pricing']['prompt'] ?? 0 ) * 1000000, + 'completion' => (float) ( $model['pricing']['completion'] ?? 0 ) * 1000000, + ], + ]; + } + } + + set_transient( 'autoblogger_openrouter_models', $models, DAY_IN_SECONDS ); + return $models; + } + + /** + * Estimate the cost of an API call in USD. + * + * Uses hardcoded pricing first, falls back to cached model data. + * + * @param string $model Model identifier. + * @param int $prompt_tokens Input tokens. + * @param int $completion_tokens Output tokens. + * + * @return float Estimated cost in USD. + */ + public function estimate_cost( string $model, int $prompt_tokens, int $completion_tokens ): float { + $pricing = self::MODEL_PRICING[ $model ] ?? null; + + // Fall back to cached model list pricing. + if ( null === $pricing ) { + $models = $this->get_available_models(); + foreach ( $models as $m ) { + if ( $m['id'] === $model && isset( $m['pricing'] ) ) { + $pricing = $m['pricing']; + break; + } + } + } + + if ( null === $pricing ) { + // Unknown model — use a conservative estimate. + Autoblogger_Logger::instance()->warning( "Unknown model pricing for '{$model}'. Using conservative estimate.", 'openrouter' ); + $pricing = [ 'prompt' => 10.0, 'completion' => 30.0 ]; + } + + return ( $prompt_tokens * $pricing['prompt'] / 1000000 ) + + ( $completion_tokens * $pricing['completion'] / 1000000 ); + } + + /** + * Get the decrypted API key from options. + * + * @return string Decrypted API key, or empty string if not set. + */ + private function get_api_key(): string { + $encrypted = get_option( 'autoblogger_openrouter_api_key', '' ); + if ( '' === $encrypted ) { + return ''; + } + return Autoblogger_Encryption::decrypt( $encrypted ); + } +} diff --git a/includes/providers/class-openrouter-provider.php b/includes/providers/class-openrouter-provider.php new file mode 100755 index 0000000..8ea4104 --- /dev/null +++ b/includes/providers/class-openrouter-provider.php @@ -0,0 +1,253 @@ + $messages Chat messages. + * @param string $model Model identifier (e.g., 'anthropic/claude-sonnet-4'). + * @param array{ + * temperature?: float, + * max_tokens?: int, + * response_format?: array{type: string}, + * } $options Optional parameters. + * + * @return array{ + * content: string, + * model: string, + * prompt_tokens: int, + * completion_tokens: int, + * total_tokens: int, + * finish_reason: string, + * } + * + * @throws \RuntimeException On API error after retries exhausted. + */ + public function send_chat_completion( array $messages, string $model, array $options = [] ): array { + $api_key = $this->get_api_key(); + if ( '' === $api_key ) { + throw new \RuntimeException( + __( 'OpenRouter API key is not configured. Go to AutoBlogger → Settings.', 'autoblogger' ) + ); + } + + $body = [ + 'model' => $model, + 'messages' => $messages, + ]; + + if ( isset( $options['temperature'] ) ) { + $body['temperature'] = $options['temperature']; + } + if ( isset( $options['max_tokens'] ) ) { + $body['max_tokens'] = $options['max_tokens']; + } + if ( isset( $options['response_format'] ) ) { + $body['response_format'] = $options['response_format']; + } + + $last_error = ''; + + for ( $attempt = 1; $attempt <= AUTOBLOGGER_MAX_RETRIES; $attempt++ ) { + $response = wp_remote_post( + self::API_BASE_URL . '/chat/completions', + [ + 'timeout' => AUTOBLOGGER_API_TIMEOUT_SECONDS, + 'headers' => [ + 'Authorization' => 'Bearer ' . $api_key, + 'Content-Type' => 'application/json', + 'HTTP-Referer' => home_url(), + 'X-Title' => 'AutoBlogger WordPress Plugin', + ], + 'body' => wp_json_encode( $body ), + ] + ); + + if ( is_wp_error( $response ) ) { + $last_error = $response->get_error_message(); + Autoblogger_Logger::instance()->warning( + sprintf( 'OpenRouter request failed (attempt %d/%d): %s', $attempt, AUTOBLOGGER_MAX_RETRIES, $last_error ), + 'openrouter' + ); + + if ( $attempt < AUTOBLOGGER_MAX_RETRIES ) { + $delay = AUTOBLOGGER_RETRY_BASE_DELAY_SECONDS * pow( 2, $attempt - 1 ); + sleep( (int) $delay ); + } + continue; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + $body_raw = wp_remote_retrieve_body( $response ); + $data = json_decode( $body_raw, true ); + + // Rate limited or server error — retry. + if ( $status_code >= 429 || $status_code >= 500 ) { + $last_error = sprintf( 'HTTP %d: %s', $status_code, $body_raw ); + Autoblogger_Logger::instance()->warning( + sprintf( 'OpenRouter HTTP %d (attempt %d/%d): %s', $status_code, $attempt, AUTOBLOGGER_MAX_RETRIES, substr( $body_raw, 0, 500 ) ), + 'openrouter' + ); + + if ( $attempt < AUTOBLOGGER_MAX_RETRIES ) { + // Respect Retry-After header if present. + $retry_after = wp_remote_retrieve_header( $response, 'retry-after' ); + $delay = $retry_after + ? min( (int) $retry_after, 60 ) + : AUTOBLOGGER_RETRY_BASE_DELAY_SECONDS * pow( 2, $attempt - 1 ); + sleep( (int) $delay ); + } + continue; + } + + // Client error — don't retry, fail immediately. + if ( $status_code >= 400 ) { + $error_msg = isset( $data['error']['message'] ) + ? $data['error']['message'] + : 'HTTP ' . $status_code; + throw new \RuntimeException( + sprintf( + /* translators: %d: HTTP status code, %s: error message */ + __( 'OpenRouter API error (HTTP %1$d): %2$s', 'autoblogger' ), + $status_code, + $error_msg + ) + ); + } + + // Success — parse response. + if ( ! isset( $data['choices'][0]['message']['content'] ) ) { + throw new \RuntimeException( + __( 'OpenRouter returned unexpected response format.', 'autoblogger' ) + ); + } + + $usage = $data['usage'] ?? []; + + return [ + 'content' => $data['choices'][0]['message']['content'], + 'model' => $data['model'] ?? $model, + 'prompt_tokens' => (int) ( $usage['prompt_tokens'] ?? 0 ), + 'completion_tokens' => (int) ( $usage['completion_tokens'] ?? 0 ), + 'total_tokens' => (int) ( $usage['total_tokens'] ?? 0 ), + 'finish_reason' => $data['choices'][0]['finish_reason'] ?? 'unknown', + ]; + } + + // All retries exhausted. + throw new \RuntimeException( + sprintf( + /* translators: %d: max retries, %s: last error message */ + __( 'OpenRouter API failed after %1$d attempts. Last error: %2$s', 'autoblogger' ), + AUTOBLOGGER_MAX_RETRIES, + $last_error + ) + ); + } + + /** + * Get available models from OpenRouter's /models endpoint. + * + * Caches the result in a transient for 24 hours to avoid repeated API calls. + * + * Side effects: HTTP request (if cache miss), sets transient. + * + * @return array + */ + public function get_available_models(): array { + $pricing = new Autoblogger_OpenRouter_Pricing(); + return $pricing->get_available_models(); + } + + /** + * Estimate the cost of an API call in USD. + * + * Uses hardcoded pricing first, falls back to cached model data. + * + * @param string $model Model identifier. + * @param int $prompt_tokens Input tokens. + * @param int $completion_tokens Output tokens. + * + * @return float Estimated cost in USD. + */ + public function estimate_cost( string $model, int $prompt_tokens, int $completion_tokens ): float { + $pricing = new Autoblogger_OpenRouter_Pricing(); + return $pricing->estimate_cost( $model, $prompt_tokens, $completion_tokens ); + } + + /** + * @return string + */ + public function get_provider_name(): string { + return 'OpenRouter'; + } + + /** + * Validate that the OpenRouter API key is configured and working. + * + * Makes a lightweight request to the /auth/key endpoint. + * + * Side effects: HTTP request to OpenRouter. + * + * @return bool + */ + public function validate_credentials(): bool { + $api_key = $this->get_api_key(); + if ( '' === $api_key ) { + return false; + } + + $response = wp_remote_get( + self::API_BASE_URL . '/auth/key', + [ + 'timeout' => 15, + 'headers' => [ + 'Authorization' => 'Bearer ' . $api_key, + ], + ] + ); + + if ( is_wp_error( $response ) ) { + return false; + } + + return 200 === wp_remote_retrieve_response_code( $response ); + } + + /** + * Get the decrypted API key from options. + * + * @return string Decrypted API key, or empty string if not set. + */ + private function get_api_key(): string { + $encrypted = get_option( 'autoblogger_openrouter_api_key', '' ); + if ( '' === $encrypted ) { + return ''; + } + return Autoblogger_Encryption::decrypt( $encrypted ); + } +} diff --git a/includes/providers/class-reddit-api-client.php b/includes/providers/class-reddit-api-client.php new file mode 100755 index 0000000..557cfc8 --- /dev/null +++ b/includes/providers/class-reddit-api-client.php @@ -0,0 +1,207 @@ + 15, + 'headers' => [ + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + 'Authorization' => 'Basic ' . base64_encode( $client_id . ':' . $client_secret ), + 'User-Agent' => self::USER_AGENT, + ], + 'body' => [ + 'grant_type' => 'client_credentials', + ], + ] + ); + + if ( is_wp_error( $response ) ) { + Autoblogger_Logger::instance()->error( 'Reddit OAuth failed: ' . $response->get_error_message(), 'reddit' ); + return ''; + } + + if ( 200 !== wp_remote_retrieve_response_code( $response ) ) { + Autoblogger_Logger::instance()->error( 'Reddit OAuth HTTP ' . wp_remote_retrieve_response_code( $response ), 'reddit' ); + return ''; + } + + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( ! isset( $data['access_token'] ) ) { + Autoblogger_Logger::instance()->error( 'Reddit OAuth response missing access_token.', 'reddit' ); + return ''; + } + + $token = $data['access_token']; + $expires = isset( $data['expires_in'] ) ? (int) $data['expires_in'] - 60 : 3540; + + set_transient( 'autoblogger_reddit_token', $token, $expires ); + + return $token; + } + + /** + * Fetch posts from a subreddit. + * + * @param string $token OAuth access token. + * @param string $subreddit Subreddit name (without r/ prefix). + * @param int $limit Max posts to fetch. + * @param string $time_filter Time filter for /top (hour, day, week, month, year, all). + * + * @return array> Array of post data. + */ + public function fetch_posts( string $token, string $subreddit, int $limit, string $time_filter ): array { + $url = sprintf( + '%s/r/%s/hot?limit=%d&t=%s', + self::API_BASE, + rawurlencode( $subreddit ), + min( $limit, 100 ), + rawurlencode( $time_filter ) + ); + + $response = $this->api_get( $token, $url ); + if ( null === $response || ! isset( $response['data']['children'] ) ) { + return []; + } + + $posts = []; + foreach ( $response['data']['children'] as $child ) { + if ( isset( $child['data'] ) ) { + $posts[] = $child['data']; + } + } + + return $posts; + } + + /** + * Fetch top comments for a specific post. + * + * @param string $token OAuth access token. + * @param string $subreddit Subreddit name. + * @param string $post_id Reddit post ID (without t3_ prefix). + * @param int $limit Max comments to fetch. + * + * @return array> Array of comment data. + */ + public function fetch_comments( string $token, string $subreddit, string $post_id, int $limit ): array { + $url = sprintf( + '%s/r/%s/comments/%s?limit=%d&sort=top&depth=1', + self::API_BASE, + rawurlencode( $subreddit ), + rawurlencode( $post_id ), + min( $limit, 50 ) + ); + + $response = $this->api_get( $token, $url ); + + // Comments endpoint returns [post_listing, comments_listing]. + if ( ! is_array( $response ) || ! isset( $response[1]['data']['children'] ) ) { + return []; + } + + $comments = []; + foreach ( $response[1]['data']['children'] as $child ) { + if ( isset( $child['data'] ) && 't1' === ( $child['kind'] ?? '' ) ) { + $comments[] = $child['data']; + } + } + + return $comments; + } + + /** + * Make an authenticated GET request to the Reddit API. + * + * Updates rate limit tracking from response headers. + * + * @param string $token OAuth access token. + * @param string $url Full API URL. + * + * @return array|null Decoded response, or null on failure. + */ + public function api_get( string $token, string $url ): ?array { + $response = wp_remote_get( + $url, + [ + 'timeout' => 30, + 'headers' => [ + 'Authorization' => 'Bearer ' . $token, + 'User-Agent' => self::USER_AGENT, + ], + ] + ); + + if ( is_wp_error( $response ) ) { + Autoblogger_Logger::instance()->error( 'Reddit API GET failed: ' . $response->get_error_message(), 'reddit' ); + return null; + } + + // Track rate limits from response headers. + $rate_remaining = wp_remote_retrieve_header( $response, 'x-ratelimit-remaining' ); + $rate_limit = wp_remote_retrieve_header( $response, 'x-ratelimit-used' ); + $rate_reset = wp_remote_retrieve_header( $response, 'x-ratelimit-reset' ); + + if ( '' !== $rate_remaining ) { + set_transient( 'autoblogger_reddit_rate_limit', [ + 'remaining' => (int) $rate_remaining, + 'limit' => (int) $rate_limit + (int) $rate_remaining, + 'resets_at' => gmdate( 'Y-m-d H:i:s', time() + (int) $rate_reset ), + ], 120 ); + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + Autoblogger_Logger::instance()->error( sprintf( 'Reddit API HTTP %d: %s', $status_code, substr( wp_remote_retrieve_body( $response ), 0, 300 ) ), 'reddit' ); + return null; + } + + return json_decode( wp_remote_retrieve_body( $response ), true ); + } +} diff --git a/includes/providers/class-reddit-provider.php b/includes/providers/class-reddit-provider.php new file mode 100755 index 0000000..f335ffd --- /dev/null +++ b/includes/providers/class-reddit-provider.php @@ -0,0 +1,178 @@ +get_access_token(); + if ( '' === $token ) { + throw new \RuntimeException( + __( 'Reddit authentication failed. Check client ID and secret in settings.', 'autoblogger' ) + ); + } + + $subreddits = $config['subreddits'] ?? $this->get_configured_subreddits(); + $limit = $config['limit'] ?? 25; + $time_filter = $config['time_filter'] ?? 'day'; + $include_comments = $config['include_comments'] ?? true; + $comments_limit = $config['comments_per_post'] ?? 10; + + $collected = []; + + foreach ( $subreddits as $subreddit ) { + $subreddit = sanitize_text_field( $subreddit ); + + try { + $posts = $api_client->fetch_posts( $token, $subreddit, $limit, $time_filter ); + + foreach ( $posts as $post ) { + $collected[] = new Autoblogger_Source_Data( [ + 'source_type' => 'reddit', + 'source_id' => 't3_' . $post['id'], + 'subreddit' => $subreddit, + 'title' => $post['title'] ?? '', + 'content' => $post['selftext'] ?? '', + 'author' => $post['author'] ?? '[deleted]', + 'score' => (int) ( $post['score'] ?? 0 ), + 'comment_count' => (int) ( $post['num_comments'] ?? 0 ), + 'permalink' => 'https://reddit.com' . ( $post['permalink'] ?? '' ), + 'collected_at' => current_time( 'mysql' ), + 'metadata' => [ + 'post_type' => $post['is_self'] ? 'self' : 'link', + 'flair' => $post['link_flair_text'] ?? null, + 'created_utc' => $post['created_utc'] ?? 0, + 'upvote_ratio' => $post['upvote_ratio'] ?? 0, + 'is_original' => $post['is_original_content'] ?? false, + ], + ] ); + + // Collect top comments for high-engagement posts. + if ( $include_comments && ( $post['num_comments'] ?? 0 ) > 5 ) { + $comments = $api_client->fetch_comments( + $token, + $subreddit, + $post['id'], + $comments_limit + ); + + foreach ( $comments as $comment ) { + $collected[] = new Autoblogger_Source_Data( [ + 'source_type' => 'reddit', + 'source_id' => 't1_' . $comment['id'], + 'subreddit' => $subreddit, + 'title' => null, + 'content' => $comment['body'] ?? '', + 'author' => $comment['author'] ?? '[deleted]', + 'score' => (int) ( $comment['score'] ?? 0 ), + 'comment_count' => 0, + 'permalink' => 'https://reddit.com' . ( $comment['permalink'] ?? '' ), + 'collected_at' => current_time( 'mysql' ), + 'metadata' => [ + 'parent_post_id' => 't3_' . $post['id'], + 'parent_title' => $post['title'] ?? '', + 'is_comment' => true, + 'created_utc' => $comment['created_utc'] ?? 0, + ], + ] ); + } + } + } + } catch ( \Exception $e ) { + // Log but continue with other subreddits. + Autoblogger_Logger::instance()->error( + sprintf( 'Reddit: Failed to collect from r/%s: %s', $subreddit, $e->getMessage() ), + 'reddit' + ); + } + } + + return $collected; + } + + /** + * @return string + */ + public function get_source_type(): string { + return 'reddit'; + } + + /** + * Validate Reddit API credentials. + * + * Side effects: HTTP request to Reddit OAuth endpoint. + * + * @return bool + */ + public function validate_credentials(): bool { + $api_client = new Autoblogger_Reddit_API_Client(); + $token = $api_client->get_access_token( true ); + return '' !== $token; + } + + /** + * Get current rate limit status from last response headers. + * + * @return array{remaining: int, limit: int, resets_at: string} + */ + public function get_rate_limit_status(): array { + // Reddit rate limits are tracked per-response via headers. + // We store the last seen values in a transient. + $status = get_transient( 'autoblogger_reddit_rate_limit' ); + if ( is_array( $status ) ) { + return $status; + } + return [ + 'remaining' => 100, + 'limit' => 100, + 'resets_at' => '', + ]; + } + + + /** + * Get the list of subreddits configured in plugin settings. + * + * @return string[] + */ + private function get_configured_subreddits(): array { + $json = get_option( 'autoblogger_target_subreddits', '[]' ); + $list = json_decode( $json, true ); + return is_array( $list ) ? $list : []; + } +} diff --git a/includes/providers/class-tiktok-provider.php b/includes/providers/class-tiktok-provider.php new file mode 100755 index 0000000..8d7367b --- /dev/null +++ b/includes/providers/class-tiktok-provider.php @@ -0,0 +1,40 @@ + $config + * @return Autoblogger_Source_Data[] + * @throws \RuntimeException Always — not yet implemented. + */ + public function collect_data( array $config ): array { + throw new \RuntimeException( + __( 'TikTok source provider is not yet implemented. Remove "tiktok" from enabled sources.', 'autoblogger' ) + ); + } + + public function get_source_type(): string { + return 'tiktok'; + } + + public function validate_credentials(): bool { + return false; + } + + public function get_rate_limit_status(): array { + return [ 'remaining' => 0, 'limit' => 0, 'resets_at' => '' ]; + } +} diff --git a/includes/providers/class-youtube-provider.php b/includes/providers/class-youtube-provider.php new file mode 100755 index 0000000..c57948d --- /dev/null +++ b/includes/providers/class-youtube-provider.php @@ -0,0 +1,39 @@ + $config + * @return Autoblogger_Source_Data[] + * @throws \RuntimeException Always — not yet implemented. + */ + public function collect_data( array $config ): array { + throw new \RuntimeException( + __( 'YouTube source provider is not yet implemented. Remove "youtube" from enabled sources.', 'autoblogger' ) + ); + } + + public function get_source_type(): string { + return 'youtube'; + } + + public function validate_credentials(): bool { + return false; + } + + public function get_rate_limit_status(): array { + return [ 'remaining' => 0, 'limit' => 0, 'resets_at' => '' ]; + } +} diff --git a/includes/providers/interface-llm-provider.php b/includes/providers/interface-llm-provider.php new file mode 100755 index 0000000..998e127 --- /dev/null +++ b/includes/providers/interface-llm-provider.php @@ -0,0 +1,71 @@ + $messages Chat messages array. + * @param string $model Model identifier (e.g., 'anthropic/claude-sonnet-4'). + * @param array{ + * temperature?: float, + * max_tokens?: int, + * response_format?: array{type: string}, + * } $options Optional parameters for the API call. + * + * @return array{ + * content: string, + * model: string, + * prompt_tokens: int, + * completion_tokens: int, + * total_tokens: int, + * finish_reason: string, + * } Normalized response. + * + * @throws \RuntimeException On API error after retries exhausted. + */ + public function send_chat_completion( array $messages, string $model, array $options = [] ): array; + + /** + * Get the list of available models from the provider. + * + * @return array Available models. + */ + public function get_available_models(): array; + + /** + * Estimate the cost of an API call based on token counts. + * + * @param string $model Model identifier. + * @param int $prompt_tokens Number of input tokens. + * @param int $completion_tokens Number of output tokens. + * + * @return float Estimated cost in USD. + */ + public function estimate_cost( string $model, int $prompt_tokens, int $completion_tokens ): float; + + /** + * Get the human-readable name of this provider. + * + * @return string Provider name (e.g., 'OpenRouter'). + */ + public function get_provider_name(): string; + + /** + * Validate that the provider's credentials are configured and working. + * + * @return bool True if credentials are valid. + */ + public function validate_credentials(): bool; +} diff --git a/includes/providers/interface-source-provider.php b/includes/providers/interface-source-provider.php new file mode 100755 index 0000000..e4eb2bb --- /dev/null +++ b/includes/providers/interface-source-provider.php @@ -0,0 +1,58 @@ + + + + + tests/unit + + + + + + includes/core + includes/models + includes/providers + + + diff --git a/readme.txt b/readme.txt new file mode 100755 index 0000000..218592b --- /dev/null +++ b/readme.txt @@ -0,0 +1,58 @@ +=== AutoBlogger === +Contributors: peptiderepo +Tags: ai, content generation, blogging, seo, automation +Requires at least: 6.0 +Tested up to: 6.7 +Requires PHP: 7.4 +Stable tag: 0.1.0 +License: GPLv2 or later +License URI: https://www.gnu.org/licenses/gpl-2.0.html + +Automatically researches trending topics from social media and generates SEO-friendly blog posts using AI. + +== Description == + +AutoBlogger monitors social media platforms (starting with Reddit) for recurring questions, complaints, and product comparisons in your niche. It uses AI to analyze these patterns, generate well-structured blog articles, run them through an editorial review agent, and publish them on a configurable daily schedule. + +**Key Features:** + +* Reddit integration for topic research (TikTok, Instagram, YouTube planned) +* Configurable AI-powered writing pipeline (single-pass or multi-step) +* Chief editor agent reviews content before publishing +* Full cost tracking with monthly budget limits +* Google Analytics 4 integration for performance metrics +* Self-improvement loop: learns from high-performing content +* Encrypted API key storage + +**Supported LLM Providers:** + +Uses OpenRouter for access to models from Anthropic, OpenAI, Google, Meta, and more. + +== Installation == + +1. Upload the `autoblogger` folder to the `/wp-content/plugins/` directory. +2. Activate the plugin through the 'Plugins' menu in WordPress. +3. Go to AutoBlogger > Settings to configure your API keys and preferences. +4. Add your OpenRouter API key and Reddit app credentials. +5. Configure your target subreddits and content preferences. +6. Click "Test Connections" to verify setup. +7. Click "Generate Now" for your first article, or wait for the daily schedule. + +== Frequently Asked Questions == + += How much does it cost to run? = + +Costs depend on your chosen models and article volume. With default settings (Claude Haiku for analysis, Claude Sonnet for writing, 1 article/day), expect roughly $0.05-0.15 per article. The plugin includes a monthly budget hard-stop to prevent overspending. + += Can I review articles before they publish? = + +By default, articles go through the chief editor agent. Articles that pass review are auto-published. Articles that fail review are saved as drafts for manual review. + += How do I set up Reddit API access? = + +Go to reddit.com/prefs/apps, create a "script" type application. Copy the client ID and secret into the AutoBlogger settings page. + +== Changelog == + += 0.1.0 = +* Initial release with Reddit source, OpenRouter LLM, and full content pipeline. diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..8aa6e55 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# ============================================================================ +# AutoBlogger Manual Deploy Script +# +# Packages the plugin into a clean zip and deploys it to WordPress via the +# deploy-receiver REST endpoint. Use this for hotfixes or local deploys when +# you don't want to go through the full GitHub Actions pipeline. +# +# Usage: +# ./scripts/deploy.sh +# +# Environment variables (set in .env or export before running): +# DEPLOY_URL — Full URL to the deploy endpoint +# DEPLOY_KEY — The deploy authentication token +# +# If not set, the script will look for a .env file in the project root. +# +# @see .github/workflows/deploy.yml — Automated pipeline (same deploy step). +# @see .github/mu-plugins/autoblogger-deploy-receiver.php — Server endpoint. +# ============================================================================ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +# Colors for output. +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +info() { echo -e "${GREEN}[deploy]${NC} $*"; } +warn() { echo -e "${YELLOW}[deploy]${NC} $*"; } +error() { echo -e "${RED}[deploy]${NC} $*" >&2; } + +# ── Load environment ──────────────────────────────────────────────────────── + +if [[ -f "$PROJECT_ROOT/.env" ]]; then + # shellcheck source=/dev/null + source "$PROJECT_ROOT/.env" +fi + +if [[ -z "${DEPLOY_URL:-}" ]]; then + error "DEPLOY_URL not set. Export it or add to .env" + exit 1 +fi + +if [[ -z "${DEPLOY_KEY:-}" ]]; then + error "DEPLOY_KEY not set. Export it or add to .env" + exit 1 +fi + +# ── Read version ──────────────────────────────────────────────────────────── + +VERSION=$(grep -oP 'Version:\s*\K[0-9]+\.[0-9]+\.[0-9]+' "$PROJECT_ROOT/autoblogger.php" || echo "0.0.0") +info "AutoBlogger v${VERSION}" + +# ── PHP lint check ────────────────────────────────────────────────────────── + +info "Running PHP syntax check..." +LINT_ERRORS=0 +while IFS= read -r -d '' file; do + if ! php -l "$file" > /dev/null 2>&1; then + error "Syntax error in: $file" + php -l "$file" + LINT_ERRORS=$((LINT_ERRORS + 1)) + fi +done < <(find "$PROJECT_ROOT" -name '*.php' -not -path '*/vendor/*' -not -path '*/tests/*' -not -path '*/.github/*' -print0) + +if [[ $LINT_ERRORS -gt 0 ]]; then + error "$LINT_ERRORS file(s) have syntax errors. Aborting deploy." + exit 1 +fi +info "Lint passed." + +# ── Package ───────────────────────────────────────────────────────────────── + +BUILD_DIR=$(mktemp -d) +PLUGIN_DIR="$BUILD_DIR/autoblogger" +ZIP_PATH="$BUILD_DIR/autoblogger-${VERSION}.zip" + +info "Packaging plugin..." + +mkdir -p "$PLUGIN_DIR" + +# Copy production files, excluding dev artifacts. +rsync -a \ + --exclude='.git' \ + --exclude='.github' \ + --exclude='.gitignore' \ + --exclude='.distignore' \ + --exclude='.env' \ + --exclude='.env.local' \ + --exclude='tests/' \ + --exclude='scripts/' \ + --exclude='vendor/' \ + --exclude='node_modules/' \ + --exclude='composer.json' \ + --exclude='composer.lock' \ + --exclude='phpunit.xml' \ + --exclude='phpunit.xml.dist' \ + --exclude='.phpcs.xml' \ + --exclude='ARCHITECTURE.md' \ + --exclude='CONVENTIONS.md' \ + --exclude='CHANGELOG.md' \ + --exclude='build/' \ + --exclude='*.log' \ + --exclude='.DS_Store' \ + "$PROJECT_ROOT/" "$PLUGIN_DIR/" + +cd "$BUILD_DIR" +zip -rq "$ZIP_PATH" autoblogger/ + +ZIP_SIZE=$(du -h "$ZIP_PATH" | cut -f1) +info "Built ${ZIP_PATH} (${ZIP_SIZE})" + +# ── Pre-deploy health check ──────────────────────────────────────────────── + +STATUS_URL="${DEPLOY_URL%/deploy}/status" +info "Checking deploy endpoint health..." +STATUS_RESPONSE=$(curl -sS -w "\n%{http_code}" \ + -H "X-Deploy-Key: ${DEPLOY_KEY}" \ + "${STATUS_URL}" 2>&1) || true + +STATUS_HTTP=$(echo "$STATUS_RESPONSE" | tail -1) +if [[ "$STATUS_HTTP" == "200" ]]; then + STATUS_BODY=$(echo "$STATUS_RESPONSE" | head -n -1) + info "Endpoint healthy: ${STATUS_BODY}" +else + warn "Health check returned HTTP ${STATUS_HTTP} — proceeding anyway." +fi + +# ── Deploy ────────────────────────────────────────────────────────────────── + +info "Deploying to ${DEPLOY_URL}..." + +RESPONSE=$(curl -sS -w "\n%{http_code}" \ + -X POST "${DEPLOY_URL}" \ + -H "X-Deploy-Key: ${DEPLOY_KEY}" \ + -F "plugin=@${ZIP_PATH}" \ + --max-time 120) + +HTTP_CODE=$(echo "$RESPONSE" | tail -1) +BODY=$(echo "$RESPONSE" | head -n -1) + +if [[ "$HTTP_CODE" -eq 200 ]]; then + info "Deployment successful!" + echo "$BODY" | python3 -m json.tool 2>/dev/null || echo "$BODY" +else + error "Deployment FAILED (HTTP ${HTTP_CODE}):" + echo "$BODY" | python3 -m json.tool 2>/dev/null || echo "$BODY" + rm -rf "$BUILD_DIR" + exit 1 +fi + +# ── Cleanup ───────────────────────────────────────────────────────────────── + +rm -rf "$BUILD_DIR" +info "Done. AutoBlogger v${VERSION} is live." diff --git a/templates/admin/log-viewer.php b/templates/admin/log-viewer.php new file mode 100755 index 0000000..ee3b5d0 --- /dev/null +++ b/templates/admin/log-viewer.php @@ -0,0 +1,134 @@ + __( 'All', 'autoblogger' ), + 'error' => __( 'Errors', 'autoblogger' ), + 'warning' => __( 'Warnings', 'autoblogger' ), + 'info' => __( 'Info', 'autoblogger' ), + 'debug' => __( 'Debug', 'autoblogger' ), +]; +?> +
+
+
+ +

+
+
+ +
+
+ + + + +
+
+ $lv_label ) : ?> + + + + +
+ + +
+ + +

+ ' . esc_html( number_format( $total ) ) . '' + ); + ?> +

+ + +
+ +

+
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+ +
+ + 1 ) : ?> +
+ add_query_arg( 'paged', '%#%' ), + 'format' => '', + 'current' => $paged, + 'total' => $total_pages, + 'prev_text' => '«', + 'next_text' => '»', + ] ) ); + ?> +
+ + +
diff --git a/templates/admin/metabox-generation-info.php b/templates/admin/metabox-generation-info.php new file mode 100755 index 0000000..5a9a58e --- /dev/null +++ b/templates/admin/metabox-generation-info.php @@ -0,0 +1,75 @@ + get_post_meta( $post->ID, '_autoblogger_topic', true ), + 'type' => get_post_meta( $post->ID, '_autoblogger_article_type', true ), + 'model' => get_post_meta( $post->ID, '_autoblogger_model_used', true ), + 'pipeline' => get_post_meta( $post->ID, '_autoblogger_pipeline_mode', true ), + 'verdict' => get_post_meta( $post->ID, '_autoblogger_editor_verdict', true ), + 'editor_notes' => get_post_meta( $post->ID, '_autoblogger_editor_notes', true ), + 'quality_score' => get_post_meta( $post->ID, '_autoblogger_quality_score', true ), + 'seo_score' => get_post_meta( $post->ID, '_autoblogger_seo_score', true ), + 'generated_at' => get_post_meta( $post->ID, '_autoblogger_generated_at', true ), + 'keywords' => get_post_meta( $post->ID, '_autoblogger_target_keywords', true ), +]; +?> +
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+

+ + +

+

+ + + + +

+ +

+ + % +  |  + + % +

+ + +

+
+ +

+ + +

+ + +

+ +
diff --git a/templates/admin/metrics-page.php b/templates/admin/metrics-page.php new file mode 100755 index 0000000..03c3273 --- /dev/null +++ b/templates/admin/metrics-page.php @@ -0,0 +1,115 @@ +get_monthly_spend(); +$budget = (float) get_option( 'autoblogger_monthly_budget_usd', 50.00 ); +$utilization = $cost_tracker->get_budget_utilization(); +$daily_spend = $cost_tracker->get_daily_spend( 30 ); + +$first_of_month = gmdate( 'Y-m-01' ); +$today = gmdate( 'Y-m-d' ); +$stage_breakdown = $cost_tracker->get_spend_by_stage( $first_of_month, $today ); +?> +
+

+ + +
+
+

+
+ $ +
+
+ +
+
+ +
+

+
+ % +
+
+
+
+
+ +
+

+
+ +
+
+
+ + +

+ + + + + + + + + + + + + + $data ) : ?> + + + + + + + + + +
$
+ + +

+ + + + + + + + + + + + $cost ) : ?> + + + + + + + +
$
+
diff --git a/templates/admin/review-queue.php b/templates/admin/review-queue.php new file mode 100755 index 0000000..38dd446 --- /dev/null +++ b/templates/admin/review-queue.php @@ -0,0 +1,154 @@ + +
+

+ + 0 && '' !== $bulk_type ) : ?> +
+

+ +

+
+ + + + + have_posts() ) : ?> +
+

+
+ +
+ + +
+ + + + + found_posts, 'autoblogger' ) ), + $query->found_posts + ); + ?> + +
+ + + + + + + + + + + + + + + + have_posts() ) : $query->the_post(); ?> + + + + + + + + + + + + + + +
+ + + + +
+ +
+ + + + + + + + +
+
+ + max_num_pages; + if ( $total_pages > 1 ) : + $current_page = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1; + echo '
'; + echo wp_kses_post( paginate_links( [ + 'base' => add_query_arg( 'paged', '%#%' ), + 'format' => '', + 'current' => $current_page, + 'total' => $total_pages, + 'prev_text' => '«', + 'next_text' => '»', + ] ) ); + echo '
'; + endif; + ?> + +
diff --git a/templates/admin/settings-page.php b/templates/admin/settings-page.php new file mode 100755 index 0000000..bc58951 --- /dev/null +++ b/templates/admin/settings-page.php @@ -0,0 +1,111 @@ +get_monthly_spend(); +$budget = (float) get_option( 'autoblogger_monthly_budget_usd', 50.00 ); +$utilization = $budget > 0 ? ( $monthly_spend / $budget ) * 100.0 : 0; +$next_run = wp_next_scheduled( 'autoblogger_daily_generation' ); +?> +
+ + +
+
+ +
+

+ v +
+
+
+ + +
+
+ + +
+
+ + + $ + / $ + +
+
+ + + ' . esc_html__( 'Not scheduled', 'autoblogger' ) . ''; ?> + +
+
+ + + + +
+
+ + + + +
+ + +
+
+ + + $sec ) : ?> +
+
+

+ +

+ +
+ + + +
+ + +
+ +
+
+
+
+
diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100755 index 0000000..2063303 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,65 @@ + 'BPC-157 dosage protocols', + 'article_type' => 'question', + 'suggested_title' => 'BPC-157 Dosage Guide: What the Research Says', + 'summary' => 'Recurring questions about optimal dosing.', + 'score' => 0.85, + 'analysis_id' => 7, + 'source_ids' => [ 101, 102, 103 ], + 'key_points' => [ 'Dosing ranges', 'Cycling protocols', 'Stacking' ], + 'target_keywords' => [ 'bpc-157 dosage', 'bpc-157 protocol' ], + ]; + + $idea = new Autoblogger_Article_Idea( $data ); + + $this->assertSame( 'BPC-157 dosage protocols', $idea->get_topic() ); + $this->assertSame( 'question', $idea->get_article_type() ); + $this->assertSame( 'BPC-157 Dosage Guide: What the Research Says', $idea->get_suggested_title() ); + $this->assertSame( 'Recurring questions about optimal dosing.', $idea->get_summary() ); + $this->assertEqualsWithDelta( 0.85, $idea->get_score(), 0.001 ); + $this->assertSame( 7, $idea->get_analysis_id() ); + $this->assertSame( [ 101, 102, 103 ], $idea->get_source_ids() ); + $this->assertCount( 3, $idea->get_key_points() ); + $this->assertCount( 2, $idea->get_target_keywords() ); + } + + public function test_optional_fields_default_to_empty(): void { + $idea = new Autoblogger_Article_Idea( [ + 'topic' => 'Test', + 'article_type' => 'comparison', + 'suggested_title' => 'Test Title', + 'summary' => 'Test summary', + ] ); + + $this->assertEqualsWithDelta( 0.0, $idea->get_score(), 0.001 ); + $this->assertSame( 0, $idea->get_analysis_id() ); + $this->assertSame( [], $idea->get_source_ids() ); + $this->assertSame( [], $idea->get_key_points() ); + $this->assertSame( [], $idea->get_target_keywords() ); + } + + public function test_score_cast_from_string(): void { + $idea = new Autoblogger_Article_Idea( [ + 'topic' => 'Test', + 'article_type' => 'question', + 'suggested_title' => 'Test', + 'summary' => 'Test', + 'score' => '0.75', + ] ); + + $this->assertIsFloat( $idea->get_score() ); + $this->assertEqualsWithDelta( 0.75, $idea->get_score(), 0.001 ); + } +} diff --git a/tests/unit/EditorialReviewTest.php b/tests/unit/EditorialReviewTest.php new file mode 100755 index 0000000..6938c22 --- /dev/null +++ b/tests/unit/EditorialReviewTest.php @@ -0,0 +1,86 @@ + 'approved', + 'notes' => 'Good article, ready to publish.', + 'quality_score' => 0.85, + 'seo_score' => 0.78, + 'issues' => [], + ] ); + + $this->assertSame( 'approved', $review->get_verdict() ); + $this->assertSame( 'Good article, ready to publish.', $review->get_notes() ); + $this->assertNull( $review->get_revised_content() ); + $this->assertEqualsWithDelta( 0.85, $review->get_quality_score(), 0.01 ); + $this->assertEqualsWithDelta( 0.78, $review->get_seo_score(), 0.01 ); + $this->assertEmpty( $review->get_issues() ); + } + + public function test_revised_review_with_content(): void { + $review = new Autoblogger_Editorial_Review( [ + 'verdict' => 'revised', + 'notes' => 'Fixed heading structure.', + 'revised_content' => '

Improved Article

Better content.

', + 'quality_score' => 0.80, + 'seo_score' => 0.90, + 'issues' => [ 'H2 tags were missing' ], + ] ); + + $this->assertSame( 'revised', $review->get_verdict() ); + $this->assertNotNull( $review->get_revised_content() ); + $this->assertStringContainsString( 'Improved Article', $review->get_revised_content() ); + $this->assertCount( 1, $review->get_issues() ); + } + + /** + * Edge case: "revised" verdict but null revised_content. + * This happened in production when the LLM returned a malformed response. + * The pipeline handles this with a null-coalesce fallback. + */ + public function test_revised_verdict_with_null_content(): void { + $review = new Autoblogger_Editorial_Review( [ + 'verdict' => 'revised', + 'notes' => 'Supposedly revised but no content provided.', + 'revised_content' => null, + ] ); + + $this->assertSame( 'revised', $review->get_verdict() ); + $this->assertNull( $review->get_revised_content() ); + } + + public function test_rejected_review(): void { + $review = new Autoblogger_Editorial_Review( [ + 'verdict' => 'rejected', + 'notes' => 'Off-topic and factually inaccurate.', + 'quality_score' => 0.2, + 'seo_score' => 0.1, + 'issues' => [ 'Off-topic', 'Factual errors', 'Poor structure' ], + ] ); + + $this->assertSame( 'rejected', $review->get_verdict() ); + $this->assertCount( 3, $review->get_issues() ); + $this->assertLessThan( 0.5, $review->get_quality_score() ); + } + + public function test_default_scores_are_zero(): void { + $review = new Autoblogger_Editorial_Review( [ + 'verdict' => 'rejected', + 'notes' => 'Minimal data.', + ] ); + + $this->assertEqualsWithDelta( 0.0, $review->get_quality_score(), 0.01 ); + $this->assertEqualsWithDelta( 0.0, $review->get_seo_score(), 0.01 ); + $this->assertEmpty( $review->get_issues() ); + } +} diff --git a/tests/unit/GenerationLogTest.php b/tests/unit/GenerationLogTest.php new file mode 100755 index 0000000..d049ba7 --- /dev/null +++ b/tests/unit/GenerationLogTest.php @@ -0,0 +1,128 @@ +justReturn( '2026-04-10 12:00:00' ); + } + + protected function tearDown(): void { + Monkey\tearDown(); + parent::tearDown(); + } + + public function test_constructor_sets_all_fields(): void { + $data = [ + 'id' => 42, + 'post_id' => 100, + 'run_id' => 'abc-123', + 'stage' => 'draft', + 'provider' => 'OpenRouter', + 'model' => 'anthropic/claude-sonnet-4', + 'prompt_tokens' => 1500, + 'completion_tokens' => 800, + 'estimated_cost' => 0.0165, + 'request_json' => '{"test": true}', + 'response_status' => 'success', + 'error_message' => null, + 'created_at' => '2026-04-10 10:00:00', + ]; + + $log = new Autoblogger_Generation_Log( $data ); + + $this->assertSame( 42, $log->get_id() ); + $this->assertSame( 100, $log->get_post_id() ); + $this->assertSame( 'abc-123', $log->get_run_id() ); + $this->assertSame( 'draft', $log->get_stage() ); + $this->assertSame( 'OpenRouter', $log->get_provider() ); + $this->assertSame( 'anthropic/claude-sonnet-4', $log->get_model() ); + $this->assertSame( 1500, $log->get_prompt_tokens() ); + $this->assertSame( 800, $log->get_completion_tokens() ); + $this->assertEqualsWithDelta( 0.0165, $log->get_estimated_cost(), 0.0001 ); + $this->assertSame( 'success', $log->get_response_status() ); + $this->assertNull( $log->get_error_message() ); + $this->assertSame( '2026-04-10 10:00:00', $log->get_created_at() ); + } + + public function test_constructor_defaults_for_missing_fields(): void { + $log = new Autoblogger_Generation_Log( [] ); + + $this->assertSame( 0, $log->get_id() ); + $this->assertNull( $log->get_post_id() ); + $this->assertNull( $log->get_run_id() ); + $this->assertSame( '', $log->get_stage() ); + $this->assertSame( 0, $log->get_prompt_tokens() ); + $this->assertSame( 0, $log->get_completion_tokens() ); + $this->assertEqualsWithDelta( 0.0, $log->get_estimated_cost(), 0.0001 ); + $this->assertSame( 'success', $log->get_response_status() ); + $this->assertSame( '2026-04-10 12:00:00', $log->get_created_at() ); + } + + public function test_to_db_row_excludes_id(): void { + $log = new Autoblogger_Generation_Log( [ + 'id' => 99, + 'post_id' => 50, + 'run_id' => 'run-abc', + 'stage' => 'review', + 'provider' => 'OpenRouter', + 'model' => 'anthropic/claude-3.5-haiku', + ] ); + + $row = $log->to_db_row(); + + // ID should NOT be in the DB row (it's auto-increment). + $this->assertArrayNotHasKey( 'id', $row ); + + // Key fields should be present. + $this->assertSame( 50, $row['post_id'] ); + $this->assertSame( 'run-abc', $row['run_id'] ); + $this->assertSame( 'review', $row['stage'] ); + $this->assertSame( 'OpenRouter', $row['provider'] ); + } + + public function test_to_db_row_has_all_expected_keys(): void { + $log = new Autoblogger_Generation_Log( [ 'stage' => 'analysis' ] ); + $row = $log->to_db_row(); + + $expected_keys = [ + 'post_id', 'run_id', 'stage', 'provider', 'model', + 'prompt_tokens', 'completion_tokens', 'estimated_cost', + 'request_json', 'response_status', 'error_message', 'created_at', + ]; + + foreach ( $expected_keys as $key ) { + $this->assertArrayHasKey( $key, $row, "Missing key: {$key}" ); + } + } + + public function test_numeric_fields_are_cast_correctly(): void { + $log = new Autoblogger_Generation_Log( [ + 'id' => '42', + 'post_id' => '100', + 'prompt_tokens' => '1500', + 'completion_tokens' => '800', + 'estimated_cost' => '0.0165', + ] ); + + // String values should be cast to proper types. + $this->assertSame( 42, $log->get_id() ); + $this->assertSame( 100, $log->get_post_id() ); + $this->assertSame( 1500, $log->get_prompt_tokens() ); + $this->assertSame( 800, $log->get_completion_tokens() ); + $this->assertIsFloat( $log->get_estimated_cost() ); + } +} diff --git a/tests/unit/OpenRouterPricingTest.php b/tests/unit/OpenRouterPricingTest.php new file mode 100755 index 0000000..7180650 --- /dev/null +++ b/tests/unit/OpenRouterPricingTest.php @@ -0,0 +1,147 @@ +justReturn( '' ); + Functions\when( 'get_transient' )->justReturn( false ); + Functions\when( 'set_transient' )->justReturn( true ); + + // Mock the Logger singleton so calls don't break. + // The pricing class calls Logger::instance()->warning() for unknown models. + if ( ! class_exists( 'Autoblogger_Logger' ) ) { + require_once dirname( __DIR__, 2 ) . '/includes/core/class-logger.php'; + } + + // Mock WP functions the Logger needs. + Functions\when( 'current_time' )->justReturn( '2026-04-10 12:00:00' ); + + // Mock Encryption class (used by get_api_key). + if ( ! class_exists( 'Autoblogger_Encryption' ) ) { + require_once dirname( __DIR__, 2 ) . '/includes/class-encryption.php'; + } + + // Load the class under test. + if ( ! class_exists( 'Autoblogger_OpenRouter_Pricing' ) ) { + require_once dirname( __DIR__, 2 ) . '/includes/providers/class-openrouter-pricing.php'; + } + } + + protected function tearDown(): void { + Monkey\tearDown(); + parent::tearDown(); + } + + public function test_known_model_cost_calculation(): void { + $pricing = new Autoblogger_OpenRouter_Pricing(); + + // Claude 3.5 Haiku: $0.80/M prompt, $4.00/M completion + // 1000 prompt tokens = 1000 * 0.80 / 1_000_000 = $0.0008 + // 500 completion tokens = 500 * 4.00 / 1_000_000 = $0.002 + // Total = $0.0028 + $cost = $pricing->estimate_cost( 'anthropic/claude-3.5-haiku', 1000, 500 ); + $this->assertEqualsWithDelta( 0.0028, $cost, 0.00001 ); + } + + public function test_claude_sonnet_cost(): void { + $pricing = new Autoblogger_OpenRouter_Pricing(); + + // Claude Sonnet 4: $3.00/M prompt, $15.00/M completion + // 2000 prompt + 1000 completion + // = (2000 * 3.0 / 1M) + (1000 * 15.0 / 1M) + // = 0.006 + 0.015 = 0.021 + $cost = $pricing->estimate_cost( 'anthropic/claude-sonnet-4', 2000, 1000 ); + $this->assertEqualsWithDelta( 0.021, $cost, 0.00001 ); + } + + public function test_gpt4o_mini_cost(): void { + $pricing = new Autoblogger_OpenRouter_Pricing(); + + // GPT-4o-mini: $0.15/M prompt, $0.60/M completion + // 5000 prompt + 2000 completion + // = (5000 * 0.15 / 1M) + (2000 * 0.60 / 1M) + // = 0.00075 + 0.0012 = 0.00195 + $cost = $pricing->estimate_cost( 'openai/gpt-4o-mini', 5000, 2000 ); + $this->assertEqualsWithDelta( 0.00195, $cost, 0.00001 ); + } + + public function test_zero_tokens_returns_zero_cost(): void { + $pricing = new Autoblogger_OpenRouter_Pricing(); + $cost = $pricing->estimate_cost( 'anthropic/claude-3.5-haiku', 0, 0 ); + $this->assertEqualsWithDelta( 0.0, $cost, 0.00001 ); + } + + public function test_unknown_model_uses_conservative_estimate(): void { + $pricing = new Autoblogger_OpenRouter_Pricing(); + + // Unknown model: $10.00/M prompt, $30.00/M completion (conservative fallback) + // 1000 prompt + 1000 completion + // = (1000 * 10 / 1M) + (1000 * 30 / 1M) = 0.01 + 0.03 = 0.04 + $cost = $pricing->estimate_cost( 'unknown/fake-model', 1000, 1000 ); + $this->assertEqualsWithDelta( 0.04, $cost, 0.00001 ); + } + + public function test_cost_scales_linearly_with_tokens(): void { + $pricing = new Autoblogger_OpenRouter_Pricing(); + + $cost_1x = $pricing->estimate_cost( 'anthropic/claude-3.5-haiku', 1000, 500 ); + $cost_2x = $pricing->estimate_cost( 'anthropic/claude-3.5-haiku', 2000, 1000 ); + + // 2x the tokens should be exactly 2x the cost. + $this->assertEqualsWithDelta( $cost_1x * 2, $cost_2x, 0.00001 ); + } + + public function test_prompt_and_completion_priced_separately(): void { + $pricing = new Autoblogger_OpenRouter_Pricing(); + + // For a model where prompt and completion prices differ (all of them), + // swapping prompt/completion tokens should give a different cost. + $cost_a = $pricing->estimate_cost( 'anthropic/claude-sonnet-4', 1000, 500 ); + $cost_b = $pricing->estimate_cost( 'anthropic/claude-sonnet-4', 500, 1000 ); + + // Sonnet: prompt=$3/M, completion=$15/M + // A: 1000*3/1M + 500*15/1M = 0.003 + 0.0075 = 0.0105 + // B: 500*3/1M + 1000*15/1M = 0.0015 + 0.015 = 0.0165 + $this->assertEqualsWithDelta( 0.0105, $cost_a, 0.00001 ); + $this->assertEqualsWithDelta( 0.0165, $cost_b, 0.00001 ); + $this->assertNotEquals( $cost_a, $cost_b ); + } + + /** + * Verify that a realistic full-article generation stays under reasonable cost. + * This is a sanity check: if this ever fails, pricing data may be stale. + */ + public function test_full_article_cost_sanity_check(): void { + $pricing = new Autoblogger_OpenRouter_Pricing(); + + // Typical multi-step article: analysis + outline + draft + polish + review + // Using Haiku for analysis, Sonnet for the rest. + $analysis_cost = $pricing->estimate_cost( 'anthropic/claude-3.5-haiku', 8000, 2000 ); + $outline_cost = $pricing->estimate_cost( 'anthropic/claude-sonnet-4', 3000, 1000 ); + $draft_cost = $pricing->estimate_cost( 'anthropic/claude-sonnet-4', 5000, 3000 ); + $polish_cost = $pricing->estimate_cost( 'anthropic/claude-sonnet-4', 6000, 3000 ); + $review_cost = $pricing->estimate_cost( 'anthropic/claude-sonnet-4', 5000, 2000 ); + + $total = $analysis_cost + $outline_cost + $draft_cost + $polish_cost + $review_cost; + + // A full article should cost between $0.01 and $1.00. + // If outside this range, pricing constants are probably wrong. + $this->assertGreaterThan( 0.01, $total, 'Full article too cheap — pricing data may be zero.' ); + $this->assertLessThan( 1.00, $total, 'Full article too expensive — pricing data may be inflated.' ); + } +} diff --git a/uninstall.php b/uninstall.php new file mode 100755 index 0000000..a118728 --- /dev/null +++ b/uninstall.php @@ -0,0 +1,90 @@ +prefix . 'autoblogger_'; +$tables = [ + $prefix . 'source_data', + $prefix . 'analysis_results', + $prefix . 'generation_log', + $prefix . 'content_scores', + $prefix . 'event_log', +]; + +foreach ( $tables as $table ) { + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are hardcoded above, not user input. + $wpdb->query( "DROP TABLE IF EXISTS {$table}" ); +} + +/* +|-------------------------------------------------------------------------- +| 2. Delete All Plugin Options +|-------------------------------------------------------------------------- +*/ + +$options = $wpdb->get_col( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE 'autoblogger\_%'" +); + +foreach ( $options as $option ) { + delete_option( $option ); +} + +/* +|-------------------------------------------------------------------------- +| 3. Delete All Plugin Post Meta +|-------------------------------------------------------------------------- +*/ + +$wpdb->query( + "DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE '\\_autoblogger\\_%'" +); + +/* +|-------------------------------------------------------------------------- +| 4. Delete All Plugin Transients +|-------------------------------------------------------------------------- +*/ + +$wpdb->query( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE '\\_transient\\_autoblogger\\_%' OR option_name LIKE '\\_transient\\_timeout\\_autoblogger\\_%'" +); + +/* +|-------------------------------------------------------------------------- +| 5. Clear Any Remaining Cron Events +|-------------------------------------------------------------------------- +*/ + +$hooks = [ + 'autoblogger_daily_generation', + 'autoblogger_collect_metrics', +]; + +foreach ( $hooks as $hook ) { + $timestamp = wp_next_scheduled( $hook ); + if ( false !== $timestamp ) { + wp_unschedule_event( $timestamp, $hook ); + } +}