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 <noreply@anthropic.com>
This commit is contained in:
Terence Ong
2026-04-10 16:10:27 +07:00
commit 68c6f67bcf
65 changed files with 8833 additions and 0 deletions

36
.distignore Executable file
View File

@@ -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

View File

@@ -0,0 +1,236 @@
<?php
/**
* Plugin Name: AutoBlogger Deploy Receiver
* Description: REST endpoint that accepts authenticated plugin zip uploads for automated deployment.
* Version: 1.0.0
* Author: AutoBlogger CI/CD
*
* INSTALLATION:
* 1. Upload this file to wp-content/mu-plugins/autoblogger-deploy-receiver.php
* 2. Add to wp-config.php (above "That's all, stop editing!"):
* define('AUTOBLOGGER_DEPLOY_KEY', 'your-random-64-char-token');
* 3. Generate a key: php -r "echo bin2hex(random_bytes(32));"
*
* SECURITY:
* - Deploy key validated with hash_equals() (timing-safe comparison).
* - Only accepts zip files containing autoblogger/autoblogger.php.
* - Backs up current plugin before extraction; rolls back on failure.
* - Rate limited: max 1 deploy per 60 seconds.
* - All actions logged to PHP error_log for audit trail.
*
* @see .github/workflows/deploy.yml — GitHub Actions workflow that calls this.
* @see scripts/deploy.sh — Manual deploy script that calls this.
*/
declare(strict_types=1);
// Hard requirement: deploy key must be defined in wp-config.php.
if ( ! defined( 'AUTOBLOGGER_DEPLOY_KEY' ) || '' === AUTOBLOGGER_DEPLOY_KEY ) {
return;
}
add_action( 'rest_api_init', 'autoblogger_deploy_register_route' );
/**
* Register the deploy REST route.
*/
function autoblogger_deploy_register_route(): void {
register_rest_route( 'autoblogger-deploy/v1', '/deploy', [
'methods' => '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 );
}

159
.github/workflows/deploy.yml vendored Executable file
View File

@@ -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

27
.gitignore vendored Executable file
View File

@@ -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/

333
ARCHITECTURE.md Executable file
View File

@@ -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.

30
CHANGELOG.md Executable file
View File

@@ -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.

168
CONVENTIONS.md Executable file
View File

@@ -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()`

273
assets/css/admin.css Executable file
View File

@@ -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; }
}

275
assets/js/admin.js Executable file
View File

@@ -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);

98
autoblogger.php Executable file
View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
/**
* AutoBlogger — Automated Content Research & Publishing
*
* @wordpress-plugin
* Plugin Name: AutoBlogger
* Plugin URI: https://peptiderepo.com/autoblogger
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
* Version: 0.1.0
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: PeptideRepo
* Author URI: https://peptiderepo.com
* License: GPL v2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: autoblogger
* Domain Path: /languages
*
* @see ARCHITECTURE.md — Full data flow and file tree
* @see CONVENTIONS.md — Naming patterns and extension guides
*/
// Abort if called directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/*
|--------------------------------------------------------------------------
| Plugin Constants
|--------------------------------------------------------------------------
| Defined here so every file in the plugin can reference paths, versions,
| and limits without magic strings.
*/
define( 'AUTOBLOGGER_VERSION', '0.1.0' );
define( 'AUTOBLOGGER_DB_VERSION', '1.1.0' );
define( 'AUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'AUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'AUTOBLOGGER_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'AUTOBLOGGER_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
// API and retry limits — named constants instead of magic numbers.
define( 'AUTOBLOGGER_MAX_RETRIES', 3 );
define( 'AUTOBLOGGER_RETRY_BASE_DELAY_SECONDS', 2 );
define( 'AUTOBLOGGER_API_TIMEOUT_SECONDS', 120 );
define( 'AUTOBLOGGER_CACHE_TTL_SECONDS', 3600 );
// Default models for OpenRouter (user can override in settings).
define( 'AUTOBLOGGER_DEFAULT_ANALYSIS_MODEL', 'anthropic/claude-3.5-haiku' );
define( 'AUTOBLOGGER_DEFAULT_WRITING_MODEL', 'anthropic/claude-sonnet-4' );
define( 'AUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'anthropic/claude-sonnet-4' );
/*
|--------------------------------------------------------------------------
| Autoloader
|--------------------------------------------------------------------------
*/
require_once AUTOBLOGGER_PLUGIN_DIR . 'includes/class-autoloader.php';
Autoblogger_Autoloader::register();
/*
|--------------------------------------------------------------------------
| Activation & Deactivation
|--------------------------------------------------------------------------
*/
register_activation_hook( __FILE__, [ 'Autoblogger_Activator', 'activate' ] );
register_deactivation_hook( __FILE__, [ 'Autoblogger_Deactivator', 'deactivate' ] );
/*
|--------------------------------------------------------------------------
| Boot the Plugin
|--------------------------------------------------------------------------
| The main orchestrator registers all hooks. Nothing else happens here —
| keep the bootstrap file minimal.
*/
/**
* Returns the singleton instance of the main plugin class.
*
* @return Autoblogger
*/
function autoblogger(): Autoblogger {
static $instance = null;
if ( null === $instance ) {
$instance = new Autoblogger();
}
return $instance;
}
// Initialize on plugins_loaded so all dependencies are available.
add_action( 'plugins_loaded', static function (): void {
autoblogger()->run();
} );

19
composer.json Executable file
View File

@@ -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/"
]
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/**
* Displays admin notices for onboarding, errors, and budget warnings.
*
* Triggered by: Autoblogger::register_admin_hooks() on `admin_notices`.
* Dependencies: Autoblogger_Cost_Tracker (for budget warnings).
*
* @see class-autoblogger.php — Registers the hook.
*/
class Autoblogger_Admin_Notices {
/**
* Display relevant admin notices.
*
* @return void
*/
public function on_display_notices(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$this->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(
'<div class="notice notice-warning is-dismissible"><p>%s <a href="%s">%s</a></p></div>',
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(
'<div class="notice notice-error"><p>%s</p></div>',
esc_html__( 'AutoBlogger: Monthly API budget EXCEEDED. Content generation is paused until next month or budget increase.', 'autoblogger' )
);
} elseif ( $utilization >= 80.0 ) {
printf(
'<div class="notice notice-warning"><p>%s</p></div>',
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(
'<div class="notice notice-info is-dismissible"><p>%s</p></div>',
esc_html__( 'AutoBlogger: No subreddits configured. Add target subreddits in Source Configuration to start collecting data.', 'autoblogger' )
);
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
/**
* Registers and renders the main AutoBlogger settings page in wp-admin.
*
* Uses the WordPress Settings API for option registration, sanitization, and rendering.
* Settings are defined as a declarative array — adding a new setting is one array entry.
*
* Triggered by: Autoblogger::register_admin_hooks() registers menu and settings hooks.
* Dependencies: Autoblogger_Encryption (for API key storage), Autoblogger_Settings_Fields.
*
* @see class-autoblogger.php — Registers hooks that call this class.
* @see class-settings-fields.php — Defines all settings fields and sections.
* @see CONVENTIONS.md — "How To: Add a New Admin Setting".
*/
class Autoblogger_Admin_Page {
private const PAGE_SLUG = 'autoblogger-settings';
private const OPTION_GROUP = 'autoblogger_settings_group';
/** Register the top-level AutoBlogger menu item. */
public function on_register_menu(): void {
add_menu_page(
__( 'AutoBlogger', 'autoblogger' ),
__( 'AutoBlogger', 'autoblogger' ),
'manage_options',
self::PAGE_SLUG,
[ $this, 'render_page' ],
'dashicons-edit-page',
30
);
}
/** Register all settings with the WordPress Settings API. */
public function on_register_settings(): void {
$sections = Autoblogger_Settings_Fields::get_sections();
$fields = Autoblogger_Settings_Fields::get_fields();
foreach ( $sections as $section_id => $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<string, mixed> $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( '<input type="%s" id="%s" name="%s" value="%s" class="ab-input" %s />', esc_attr( $type ), $id, $id, esc_attr( (string) $value ), $attrs );
break;
case 'password':
printf( '<input type="password" id="%s" name="%s" value="" class="ab-input" placeholder="%s" autocomplete="off" />',
$id, $id, '' !== $value ? esc_attr__( 'Saved (enter new value to change)', 'autoblogger' ) : '' );
break;
case 'textarea':
printf( '<textarea id="%s" name="%s" rows="3" class="ab-textarea">%s</textarea>', $id, $id, esc_textarea( (string) $value ) );
break;
case 'select':
printf( '<select id="%s" name="%s" class="ab-select">', $id, $id );
foreach ( ( $args['options'] ?? [] ) as $v => $label ) {
printf( '<option value="%s"%s>%s</option>', esc_attr( (string) $v ), selected( $value, (string) $v, false ), esc_html( $label ) );
}
echo '</select>';
break;
case 'toggle':
$checked = in_array( $value, [ '1', 'yes', true ], true );
printf( '<label class="ab-toggle"><input type="hidden" name="%s" value="0" /><input type="checkbox" name="%s" value="1" %s /><span class="ab-toggle-slider"></span></label>',
$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( '<label class="ab-checkbox-label"><input type="checkbox" name="%s[]" value="%s" %s%s /> %s</label>',
$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( ' <span class="ab-badge">%s</span>', esc_html( $args['badge'] ) );
}
if ( '' !== $desc ) {
printf( '<p class="ab-field-desc">%s</p>', 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 );
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/**
* WordPress Dashboard widget showing AutoBlogger generation status at a glance.
*
* Displays: last generation time, articles generated today, monthly cost,
* and budget utilization.
*
* Triggered by: Could be registered via wp_dashboard_setup hook.
* Dependencies: Autoblogger_Cost_Tracker.
*
* @see class-autoblogger.php — Can register this on wp_dashboard_setup.
*/
class Autoblogger_Dashboard_Widget {
/**
* Register the dashboard widget.
*
* @return void
*/
public function on_register_widget(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
wp_add_dashboard_widget(
'autoblogger_status',
__( 'AutoBlogger Status', 'autoblogger' ),
[ $this, 'render_widget' ]
);
}
/**
* Render the widget content.
*
* @return void
*/
public function render_widget(): void {
$cost_tracker = new Autoblogger_Cost_Tracker();
$monthly_spend = $cost_tracker->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( '<p><strong>%s</strong> $%.2f / $%.2f (%.0f%%)</p>',
esc_html__( 'Monthly Spend:', 'autoblogger' ),
$monthly_spend,
$budget,
$utilization
);
if ( false !== $next_run ) {
printf( '<p><strong>%s</strong> %s</p>',
esc_html__( 'Next Generation:', 'autoblogger' ),
esc_html( wp_date( 'M j, Y g:i A', $next_run ) )
);
}
printf(
'<p><a href="%s" class="button button-secondary">%s</a></p>',
esc_url( admin_url( 'admin.php?page=autoblogger-metrics' ) ),
esc_html__( 'View Full Metrics', 'autoblogger' )
);
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/**
* Admin page displaying structured application log entries.
*
* Provides level filtering (error/warning/info/debug), text search,
* pagination, and a one-click "Clear Old Logs" action. Reads from the
* ab_event_log table via Autoblogger_Logger::query().
*
* Triggered by: Autoblogger::register_admin_hooks() on `admin_menu`.
* Dependencies: Autoblogger_Logger.
*
* @see core/class-logger.php — Writes log entries and provides query().
* @see templates/admin/log-viewer.php — Renders the HTML.
* @see class-autoblogger.php — Registers the AJAX clear action.
*/
class Autoblogger_Log_Viewer {
/**
* Register the log viewer submenu page under AutoBlogger.
*
* @return void
*/
public function on_register_menu(): void {
add_submenu_page(
'autoblogger-settings',
__( 'Activity Log', 'autoblogger' ),
__( 'Activity Log', 'autoblogger' ),
'manage_options',
'autoblogger-logs',
[ $this, 'render_page' ]
);
}
/**
* Render the log viewer page.
*
* @return void
*/
public function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$level = isset( $_GET['level'] ) ? sanitize_key( $_GET['level'] ) : 'all';
$search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '';
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
$result = Autoblogger_Logger::query( $level, $search, $paged, 50 );
$rows = $result['rows'];
$total = $result['total'];
$total_pages = (int) ceil( $total / 50 );
include AUTOBLOGGER_PLUGIN_DIR . 'templates/admin/log-viewer.php';
}
/**
* AJAX handler: clear logs older than 30 days.
*
* Side effects: deletes rows from ab_event_log.
*
* @return void
*/
public function on_ajax_clear_logs(): void {
check_ajax_referer( 'autoblogger_clear_logs', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( '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
),
] );
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/**
* Admin page displaying cost dashboard, content scores, and generation history.
*
* Shows: monthly spend, daily cost chart, spend by pipeline stage, budget
* utilization, top/bottom performing posts, and recent generation logs.
*
* Triggered by: Autoblogger::register_admin_hooks() on `admin_menu`.
* Dependencies: Autoblogger_Cost_Tracker.
*
* @see class-autoblogger.php — Registers the hook.
* @see core/class-cost-tracker.php — Provides cost data.
*/
class Autoblogger_Metrics_Page {
/**
* Register the metrics submenu page under AutoBlogger.
*
* @return void
*/
public function on_register_menu(): void {
add_submenu_page(
'autoblogger-settings',
__( 'Metrics & Costs', 'autoblogger' ),
__( 'Metrics & Costs', 'autoblogger' ),
'manage_options',
'autoblogger-metrics',
[ $this, 'render_page' ]
);
}
/**
* Render the metrics page.
*
* @return void
*/
public function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
include AUTOBLOGGER_PLUGIN_DIR . 'templates/admin/metrics-page.php';
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/**
* Adds a metabox to posts showing AutoBlogger generation metadata.
*
* Only appears on posts that were generated by AutoBlogger (checked via
* _autoblogger_generated post meta).
*
* Triggered by: Autoblogger::register_admin_hooks() on `add_meta_boxes`.
* Dependencies: WordPress post meta API.
*
* @see class-autoblogger.php — Registers the hook.
* @see core/class-publisher.php — Writes the metadata this class displays.
*/
class Autoblogger_Post_Metabox {
/**
* Register the metabox for the post editor.
*
* @return void
*/
public function on_register_metabox(): void {
$screen = get_current_screen();
if ( ! $screen || 'post' !== $screen->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';
}
}

View File

@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
/**
* Admin page listing generated draft posts for editorial review.
*
* Provides approve (publish), edit (opens WP editor), and reject (trash)
* actions on each draft. Supports bulk approve/reject via checkboxes.
*
* Triggered by: Autoblogger::register_admin_hooks() on `admin_menu`.
* Dependencies: WordPress WP_Query, post meta API.
*
* @see class-autoblogger.php — Registers the hook and AJAX handlers.
* @see core/class-publisher.php — Creates the drafts this page displays.
* @see templates/admin/review-queue.php — Renders the HTML.
*/
class Autoblogger_Review_Queue {
/**
* Register the review queue submenu page under AutoBlogger.
*
* @return void
*/
public function on_register_menu(): void {
$hook = add_submenu_page(
'autoblogger-settings',
__( 'Review Queue', 'autoblogger' ),
__( 'Review Queue', 'autoblogger' ),
'manage_options',
'autoblogger-review-queue',
[ $this, 'render_page' ]
);
if ( false !== $hook ) {
add_action( "load-{$hook}", [ $this, 'on_handle_bulk_actions' ] );
}
}
/**
* Render the review queue page.
*
* @return void
*/
public function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
$query = $this->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 ] );
}
}

View File

@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
/**
* Declarative settings fields and sections for the AutoBlogger admin page.
*
* Centralizes all field and section definitions in one place, making it trivial
* to add new settings (just add one array entry). Decoupled from page rendering logic.
*
* Triggered by: Autoblogger_Admin_Page::on_register_settings() calls static methods here.
* Dependencies: None — contains only data definitions and static methods.
*
* @see admin/class-admin-page.php — Calls get_sections() and get_fields() to register settings.
* @see CONVENTIONS.md — "How To: Add a New Admin Setting".
*/
class Autoblogger_Settings_Fields {
/**
* Get all settings sections. Each section maps to a tab in the admin UI.
*
* @return array<string, array{title: string, icon: string, description: string}>
*/
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<int, array<string, mixed>>
*/
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' ),
],
];
}
}

226
includes/class-activator.php Executable file
View File

@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
/**
* Handles plugin activation: creates database tables, sets default options,
* and schedules the initial cron job.
*
* Triggered by: WordPress `register_activation_hook` in autoblogger.php.
* Dependencies: WordPress $wpdb, dbDelta().
*
* @see class-deactivator.php — Reverses transient/cron setup on deactivation.
* @see ARCHITECTURE.md — Database schema definitions.
*/
class Autoblogger_Activator {
/**
* Run activation tasks.
*
* Creates custom database tables if they don't exist, sets default option
* values (without overwriting existing ones), and schedules the daily
* generation cron event.
*
* Side effects: writes to wp_options, creates database tables, schedules WP-Cron event.
*
* @return void
*/
public static function activate(): void {
self::create_tables();
self::set_default_options();
self::schedule_cron();
// Store the DB version so we can run migrations on future updates.
update_option( 'autoblogger_db_version', AUTOBLOGGER_DB_VERSION, false );
}
/**
* Create all custom database tables using dbDelta.
*
* Side effects: creates/updates four database tables.
*
* @return void
*/
private static function create_tables(): void {
global $wpdb;
$charset_collate = $wpdb->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' );
}
}
}

298
includes/class-autoblogger.php Executable file
View File

@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
/**
* Main orchestrator for the AutoBlogger plugin.
*
* Registers all WordPress hooks (actions and filters) and wires up dependencies.
* This is the ONLY place hooks are registered — individual classes contain only
* business logic, not hook registration.
*
* Triggered by: autoblogger() singleton in autoblogger.php, called on `plugins_loaded`.
* Dependencies: All other plugin classes (loaded via autoloader), Autoblogger_Pipeline_Runner.
*
* @see autoblogger.php — Plugin bootstrap that instantiates this class.
* @see core/class-pipeline-runner.php — Executes the generation pipeline.
* @see ARCHITECTURE.md — Data flow diagram showing how classes interact.
* @see CONVENTIONS.md — Hook naming conventions.
*/
class Autoblogger {
/**
* Whether the plugin has been initialized.
*/
private bool $initialized = false;
/**
* Register all hooks and initialize the plugin.
*
* Called once on `plugins_loaded`. Idempotent — calling it twice is a no-op.
*
* Side effects: registers WordPress actions and filters.
*
* @return void
*/
public function run(): void {
if ( $this->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<string, array{interval: int, display: string}> $schedules Existing schedules.
*
* @return array<string, array{interval: int, display: string}> 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'
)
);
}
}

94
includes/class-autoloader.php Executable file
View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
/**
* PSR-4-inspired autoloader for AutoBlogger classes.
*
* Maps class names like `Autoblogger_Content_Generator` to file paths like
* `includes/core/class-content-generator.php` by scanning known subdirectories.
*
* Triggered by: PHP's spl_autoload when a class is first referenced.
* Dependencies: None — this is loaded before anything else.
*
* @see autoblogger.php — Registers this autoloader at boot time.
*/
class Autoblogger_Autoloader {
/**
* Directories to scan for class files, relative to the plugin's includes/ dir.
*
* @var string[]
*/
private static array $directories = [
'', // includes/ root
'admin/',
'core/',
'providers/',
'models/',
];
/**
* Register the autoloader with SPL.
*
* @return void
*/
public static function register(): void {
spl_autoload_register( [ __CLASS__, 'autoload' ] );
}
/**
* Attempt to load a class file.
*
* Converts `Autoblogger_Content_Generator` → `class-content-generator.php`
* and searches each registered subdirectory under includes/.
*
* @param string $class_name Fully qualified class name.
*
* @return void
*/
public static function autoload( string $class_name ): void {
// Only handle our own classes.
if ( 0 !== strpos( $class_name, 'Autoblogger' ) ) {
return;
}
$file_name = self::class_to_filename( $class_name );
$base_dir = AUTOBLOGGER_PLUGIN_DIR . 'includes/';
foreach ( self::$directories as $directory ) {
$file_path = $base_dir . $directory . $file_name;
if ( file_exists( $file_path ) ) {
require_once $file_path;
return;
}
}
}
/**
* Convert a class name to its expected filename.
*
* Autoblogger_Content_Generator → class-content-generator.php
* Autoblogger_LLM_Provider_Interface → interface-llm-provider.php
*
* @param string $class_name The class name to convert.
*
* @return string The expected filename.
*/
private static function class_to_filename( string $class_name ): string {
// Remove the Autoblogger_ prefix.
$name = (string) preg_replace( '/^Autoblogger_/', '', $class_name );
// Determine file prefix: interface- or class-.
$prefix = 'class-';
if ( preg_match( '/_Interface$/', $name ) ) {
$prefix = 'interface-';
$name = (string) preg_replace( '/_Interface$/', '', $name );
}
// Convert PascalCase/UPPER_CASE to kebab-case.
$name = strtolower( (string) preg_replace( '/([a-z])([A-Z])/', '$1-$2', $name ) );
$name = str_replace( '_', '-', $name );
return $prefix . $name . '.php';
}
}

67
includes/class-deactivator.php Executable file
View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/**
* Handles plugin deactivation: clears scheduled cron events and transients.
*
* Note: does NOT delete data or options — that happens in uninstall.php.
* Deactivation should be reversible (user can reactivate and pick up where they left off).
*
* Triggered by: WordPress `register_deactivation_hook` in autoblogger.php.
* Dependencies: None.
*
* @see class-activator.php — Sets up what this class tears down.
* @see uninstall.php — Permanent data removal on plugin deletion.
*/
class Autoblogger_Deactivator {
/**
* Run deactivation tasks.
*
* Side effects: removes WP-Cron events and deletes transients.
*
* @return void
*/
public static function deactivate(): void {
self::clear_cron();
self::clear_transients();
}
/**
* Remove all scheduled cron events belonging to this plugin.
*
* @return void
*/
private static function clear_cron(): void {
$hooks = [
'autoblogger_daily_generation',
'autoblogger_collect_metrics',
];
foreach ( $hooks as $hook ) {
$timestamp = wp_next_scheduled( $hook );
if ( false !== $timestamp ) {
wp_unschedule_event( $timestamp, $hook );
}
}
}
/**
* Delete all plugin transients.
*
* Side effects: deletes transients from wp_options.
*
* @return void
*/
private static function clear_transients(): void {
$transients = [
'autoblogger_reddit_token',
'autoblogger_generation_lock',
'autoblogger_openrouter_models',
];
foreach ( $transients as $transient ) {
delete_transient( $transient );
}
}
}

89
includes/class-encryption.php Executable file
View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/**
* Encrypts and decrypts sensitive option values (API keys, secrets).
*
* Uses OpenSSL AES-256-CBC with a key derived from wp_salt('auth').
* This is not bulletproof — anyone who can read wp-config.php can decrypt — but it
* prevents casual exposure of API keys in database dumps or phpMyAdmin.
*
* Triggered by: Any code that reads/writes encrypted options (settings page, providers).
* Dependencies: OpenSSL PHP extension, WordPress wp_salt() function.
*
* @see admin/class-admin-page.php — Uses this to store/retrieve API keys.
*/
class Autoblogger_Encryption {
private const METHOD = 'aes-256-cbc';
/**
* Encrypt a plaintext string.
*
* @param string $plaintext The value to encrypt.
*
* @return string Base64-encoded ciphertext with IV prepended.
*/
public static function encrypt( string $plaintext ): string {
if ( '' === $plaintext ) {
return '';
}
$key = self::get_key();
$iv_len = (int) openssl_cipher_iv_length( self::METHOD );
$iv = openssl_random_pseudo_bytes( $iv_len );
$ciphertext = openssl_encrypt( $plaintext, self::METHOD, $key, OPENSSL_RAW_DATA, $iv );
if ( false === $ciphertext ) {
Autoblogger_Logger::instance()->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 );
}
}

View File

@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
/**
* Chief Editor agent — LLM-powered editorial review of generated content.
*
* Reviews articles for quality, accuracy, SEO, and tone before publishing.
* Can approve, request revisions (and produce them), or reject content.
* Rejected content is saved as a draft for human review.
*
* Triggered by: Autoblogger::run_generation_pipeline() (step 5).
* Dependencies: LLM_Provider_Interface, Cost_Tracker.
*
* @see core/class-content-generator.php — Produces the content we review.
* @see core/class-publisher.php — Publishes approved content.
* @see ARCHITECTURE.md — Data flow step 5.
*/
class Autoblogger_Chief_Editor {
private Autoblogger_LLM_Provider_Interface $llm;
private Autoblogger_Cost_Tracker $cost_tracker;
public function __construct(
Autoblogger_LLM_Provider_Interface $llm,
Autoblogger_Cost_Tracker $cost_tracker
) {
$this->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'] ?? [] ),
] );
}
}

View File

@@ -0,0 +1,293 @@
<?php
declare(strict_types=1);
/**
* Analyzes collected source data using an LLM to detect recurring patterns:
* questions people ask, complaints/pain points, and product comparisons.
*
* Uses a cheap model (configurable) to keep analysis costs low.
* Batches source data into manageable chunks for the LLM context window.
*
* Triggered by: Autoblogger::run_generation_pipeline() (step 2).
* Dependencies: LLM_Provider_Interface, Cost_Tracker, WordPress $wpdb.
*
* @see providers/interface-llm-provider.php — LLM used for analysis.
* @see core/class-cost-tracker.php — Logs API costs.
* @see core/class-idea-scorer.php — Consumes analysis results.
* @see ARCHITECTURE.md — Data flow step 2.
*/
class Autoblogger_Content_Analyzer {
private Autoblogger_LLM_Provider_Interface $llm;
private Autoblogger_Cost_Tracker $cost_tracker;
public function __construct(
Autoblogger_LLM_Provider_Interface $llm,
Autoblogger_Cost_Tracker $cost_tracker
) {
$this->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<int, array<string, mixed>> 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<int, array<string, mixed>> $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<int, array<string, mixed>> $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 );
}
}
}

View File

@@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
/**
* Writer agent — generates blog post content from article ideas.
*
* Supports two configurable pipeline modes:
* - single_pass: One LLM call produces the complete article (cheaper, faster).
* - multi_step: Outline → Draft → Polish pipeline (higher quality, more cost).
*
* Triggered by: Autoblogger::run_generation_pipeline() (step 4).
* Dependencies: LLM_Provider_Interface, Cost_Tracker.
*
* @see core/class-chief-editor.php — Reviews output of this class.
* @see providers/interface-llm-provider.php — LLM used for generation.
* @see ARCHITECTURE.md — Data flow step 4.
*/
class Autoblogger_Content_Generator {
private Autoblogger_LLM_Provider_Interface $llm;
private Autoblogger_Cost_Tracker $cost_tracker;
public function __construct(
Autoblogger_LLM_Provider_Interface $llm,
Autoblogger_Cost_Tracker $cost_tracker
) {
$this->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 <html>, <head>, or <body> 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 <html>/<body> 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()
);
}
}

View File

@@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
/**
* Tracks all API costs, enforces monthly budget limits, and provides
* cost reporting data for the admin dashboard.
*
* Every LLM API call in the plugin is logged through this class. The budget
* enforcement is a hard stop — if the monthly budget is exceeded, no further
* API calls are made until the next month or the budget is increased.
*
* Triggered by: Every class that makes an LLM API call (Content_Analyzer,
* Content_Generator, Chief_Editor, Metrics_Collector).
* Dependencies: WordPress $wpdb, Autoblogger_OpenRouter_Provider (for cost estimation).
*
* @see core/class-content-analyzer.php — Calls log_api_call() after analysis.
* @see core/class-content-generator.php — Calls log_api_call() after each stage.
* @see core/class-chief-editor.php — Calls log_api_call() after review.
* @see admin/class-metrics-page.php — Displays cost data from this class.
* @see ARCHITECTURE.md — ab_generation_log table schema.
*/
class Autoblogger_Cost_Tracker {
/**
* Running cost for the current pipeline execution.
* Reset each time the pipeline starts.
*/
private float $current_run_cost = 0.0;
/**
* Unique identifier for the current pipeline run.
* Used to link generation log entries to specific posts without timestamp-based guessing.
*/
private ?string $run_id = null;
/**
* Set the run_id for the current pipeline execution.
*
* All subsequent log_api_call() entries will be tagged with this ID so
* link_generation_logs() can attribute them to the correct post.
*
* @param string $run_id UUID or unique string for this run.
*
* @return void
*/
public function set_run_id( string $run_id ): void {
$this->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<string, float> 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<string, array{cost: float, tokens: int, calls: int}> 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;
}
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
/**
* Google Analytics 4 API client for fetching post performance metrics.
*
* Handles OAuth2 service account authentication and the GA4 Data API.
* Decoupled from metrics collection logic for testability and reusability.
*
* Triggered by: Autoblogger_Metrics_Collector::fetch_ga4_data() calls methods here.
* Dependencies: Autoblogger_Encryption (for credentials decryption), wp_remote_post/get().
*
* @see core/class-metrics-collector.php — Instantiates this class.
* @see ARCHITECTURE.md — External API integrations table.
*/
class Autoblogger_GA4_Client {
/**
* Fetch Google Analytics 4 data for given posts.
*
* Returns empty data if GA4 is not configured or API call fails.
*
* Side effects: HTTP request to GA4 API (if configured).
*
* @param \WP_Post[] $posts
*
* @return array<int, array{pageviews: int, avg_time_on_page: float, bounce_rate: float}>
*/
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'];
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
/**
* Ranks and deduplicates article ideas from analysis results.
*
* Compares against existing published posts to avoid topic overlap,
* scores ideas by relevance, frequency, and freshness, then returns
* the top N ideas matching the daily target.
*
* Triggered by: Autoblogger::run_generation_pipeline() (step 3).
* Dependencies: WordPress $wpdb, WP_Query.
*
* @see core/class-content-analyzer.php — Produces the analysis results we consume.
* @see core/class-content-generator.php — Consumes the ranked ideas we produce.
* @see ARCHITECTURE.md — Data flow step 3.
*/
class Autoblogger_Idea_Scorer {
/**
* Score, deduplicate, and rank article ideas.
*
* @param Autoblogger_Analysis_Result[] $analysis_results Raw analysis output.
* @param int $target_count Number of ideas to return.
*
* @return Autoblogger_Article_Idea[] Top-ranked ideas, sorted by score descending.
*/
public function score_and_rank( array $analysis_results, int $target_count ): array {
$exclusions = json_decode( get_option( 'autoblogger_topic_exclusions', '[]' ), true ) ?: [];
$ideas = [];
foreach ( $analysis_results as $result ) {
// Skip excluded topics.
if ( $this->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';
}
}

220
includes/core/class-logger.php Executable file
View File

@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
/**
* Structured logging with configurable verbosity levels.
*
* Writes log entries to a custom database table and optionally forwards
* to PHP's error_log() for server-level monitoring. The log level is
* user-configurable via Settings → AutoBlogger → General.
*
* Levels (ascending verbosity): error → warning → info → debug.
* Setting the level to "info" captures error, warning, and info — not debug.
*
* Triggered by: Every class that previously called error_log() directly.
* Dependencies: WordPress $wpdb.
*
* @see admin/class-log-viewer.php — Reads and displays log entries.
* @see class-activator.php — Creates the ab_event_log table.
* @see ARCHITECTURE.md — Logging architecture section.
*/
class Autoblogger_Logger {
/** Log levels, lower = more severe. */
public const LEVEL_ERROR = 0;
public const LEVEL_WARNING = 1;
public const LEVEL_INFO = 2;
public const LEVEL_DEBUG = 3;
private const LEVEL_MAP = [
'error' => 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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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 ) )
)
);
}
}

View File

@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
/**
* Collects post performance metrics from WordPress native data and Google Analytics 4.
*
* Runs on a separate cron schedule (every 6 hours) to gather pageviews, bounce rate,
* time on page, and comments for AutoBlogger-generated posts. Computes a composite
* "content score" that feeds back into the analysis engine for self-improvement.
*
* Triggered by: WP-Cron hook `autoblogger_collect_metrics` (every 6 hours).
* Dependencies: WordPress $wpdb, Autoblogger_GA4_Client (for GA4 API calls).
*
* @see core/class-ga4-client.php — Handles GA4 API authentication and requests.
* @see core/class-content-analyzer.php — Reads scores for self-improvement loop.
* @see admin/class-metrics-page.php — Displays collected metrics.
* @see ARCHITECTURE.md — Data flow step 7.
*/
class Autoblogger_Metrics_Collector {
/**
* Collect metrics for all AutoBlogger-generated posts.
*
* Gathers WordPress native metrics (comments) and GA4 data (if configured),
* then computes composite scores.
*
* Side effects: GA4 API calls (if configured), database writes.
*
* @return int Number of posts scored.
*/
public function collect_all(): int {
$posts = $this->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<string, float>}
*/
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() );
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
/**
* Executes the full content generation pipeline.
*
* Orchestrates: Budget check → Collect source data → Analyze patterns →
* Score ideas → Generate content → Editorial review → Publish.
*
* Triggered by: Autoblogger::on_daily_generation() and Autoblogger::on_ajax_generate_now().
* Dependencies: All core content classes (Collector, Analyzer, Scorer, Generator, Editor, Publisher).
*
* @see class-autoblogger.php — Instantiates this class and calls run().
* @see core/class-cost-tracker.php — Tracks cumulative API costs throughout the run.
* @see ARCHITECTURE.md — Data flow diagram showing pipeline steps.
*/
class Autoblogger_Pipeline_Runner {
/**
* Execute the full content generation pipeline.
*
* Steps: Budget check → Collect source data → Analyze patterns →
* Score ideas → Generate content → Editorial review → Publish.
*
* Side effects: API calls (OpenRouter, Reddit), database writes, post creation.
*
* @return array{generated: int, published: int, rejected: int, cost: float} Pipeline results.
*
* @throws \RuntimeException If budget is exceeded or critical step fails.
*/
public function run(): array {
$cost_tracker = new Autoblogger_Cost_Tracker();
// Tag all log entries in this run with a unique ID so link_generation_logs()
// can accurately attribute costs to the correct post in batch runs.
$cost_tracker->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,
];
}
}

269
includes/core/class-publisher.php Executable file
View File

@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
/**
* Creates WordPress posts from generated and editor-approved content.
*
* Handles both publishing (editor-approved) and saving as draft (editor-rejected).
* Stores all generation metadata as post_meta for transparency and auditability.
*
* Triggered by: Autoblogger::run_generation_pipeline() (step 6).
* Dependencies: WordPress wp_insert_post(), post meta API.
*
* @see core/class-chief-editor.php — Produces the editorial review we consume.
* @see core/class-content-generator.php — Produces the content we publish.
* @see ARCHITECTURE.md — Data flow step 6.
*/
class Autoblogger_Publisher {
/**
* Publish an editor-approved article.
*
* Creates a WordPress post with 'publish' status and stores all generation
* metadata as post_meta.
*
* Side effects: Creates a WordPress post, writes post_meta.
*
* @param string $content The final HTML content.
* @param Autoblogger_Article_Idea $idea The original article idea.
* @param Autoblogger_Editorial_Review $review The editor's review.
* @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 publish(
string $content,
Autoblogger_Article_Idea $idea,
Autoblogger_Editorial_Review $review,
?string $run_id = null
): int {
return $this->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<string, mixed>
*/
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;
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
/**
* Orchestrates data collection from all enabled source providers.
*
* Iterates through enabled sources (Reddit, TikTok, etc.), calls their
* collect_data() method, and stores results in the ab_source_data table.
* Handles deduplication via UNIQUE index on (source_type, source_id).
*
* Triggered by: Autoblogger::run_generation_pipeline() (step 1).
* Dependencies: Source provider implementations, WordPress $wpdb.
*
* @see providers/interface-source-provider.php — Interface providers implement.
* @see providers/class-reddit-provider.php — Primary source provider.
* @see ARCHITECTURE.md — Data flow step 1.
*/
class Autoblogger_Source_Collector {
/**
* Collect data from all enabled source providers and store in database.
*
* Side effects: HTTP requests to source APIs, database inserts.
*
* @return int Total number of new records inserted.
*/
public function collect_from_all_sources(): int {
$enabled = json_decode( get_option( 'autoblogger_enabled_sources', '["reddit"]' ), true );
if ( ! is_array( $enabled ) ) {
$enabled = [ 'reddit' ];
}
$total_inserted = 0;
foreach ( $enabled as $source_type ) {
$provider = $this->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<string, string> $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<string, mixed> 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 [];
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/**
* Value object representing an analyzed topic pattern (recurring question,
* complaint, or comparison detected in source data).
*
* Triggered by: Content_Analyzer creates these; Idea_Scorer consumes them.
* Dependencies: None — pure data object.
*
* @see core/class-content-analyzer.php — Creates these from source data + LLM analysis.
* @see core/class-idea-scorer.php — Ranks these into article ideas.
*/
class Autoblogger_Analysis_Result {
private int $id;
private string $analysis_type;
private string $topic;
private ?string $summary;
private int $frequency;
private float $relevance_score;
private array $source_ids;
private string $analyzed_at;
private ?array $metadata;
/**
* @param array{
* id?: int,
* analysis_type: string,
* topic: string,
* summary?: string|null,
* frequency?: int,
* relevance_score?: float,
* source_ids?: int[],
* analyzed_at?: string,
* metadata?: array|null,
* } $data
*/
public function __construct( array $data ) {
$this->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<string, mixed>
*/
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,
];
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
/**
* Value object representing a scored article idea ready for content generation.
*
* Created by the Idea_Scorer from Analysis_Results. Consumed by Content_Generator.
*
* Triggered by: Idea_Scorer creates these; Content_Generator and Chief_Editor consume them.
* Dependencies: None — pure data object.
*
* @see core/class-idea-scorer.php — Creates and ranks these.
* @see core/class-content-generator.php — Uses these as generation input.
*/
class Autoblogger_Article_Idea {
private string $topic;
private string $article_type;
private string $suggested_title;
private string $summary;
private float $score;
private int $analysis_id;
private array $source_ids;
private array $key_points;
private array $target_keywords;
/**
* @param array{
* topic: string,
* article_type: string,
* suggested_title: string,
* summary: string,
* score?: float,
* analysis_id?: int,
* source_ids?: int[],
* key_points?: string[],
* target_keywords?: string[],
* } $data
*/
public function __construct( array $data ) {
$this->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;
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/**
* Value object encapsulating all parameters for a content generation request.
*
* Bundles the article idea with user-configured generation preferences
* (tone, word count, pipeline mode) into a single object passed through the pipeline.
*
* Triggered by: Content_Generator creates these from Article_Ideas + settings.
* Dependencies: None — pure data object.
*
* @see core/class-content-generator.php — Creates and consumes these.
*/
class Autoblogger_Content_Request {
private Autoblogger_Article_Idea $idea;
private string $pipeline_mode;
private string $tone;
private int $min_word_count;
private int $max_word_count;
private string $niche_description;
private array $topic_exclusions;
public function __construct(
Autoblogger_Article_Idea $idea,
string $pipeline_mode,
string $tone,
int $min_word_count,
int $max_word_count,
string $niche_description,
array $topic_exclusions = []
) {
$this->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;
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/**
* Value object for a post's composite performance score.
*
* Triggered by: Metrics_Collector creates these; Content_Analyzer reads them for self-improvement.
* Dependencies: None — pure data object.
*
* @see core/class-metrics-collector.php — Creates these from WP + GA4 data.
* @see core/class-content-analyzer.php — Reads past scores to refine analysis.
*/
class Autoblogger_Content_Score {
private int $id;
private int $post_id;
private int $pageviews;
private float $avg_time_on_page;
private float $bounce_rate;
private int $comment_count;
private float $composite_score;
private ?array $score_factors;
private string $measured_at;
/**
* @param array<string, mixed> $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<string, mixed>
*/
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,
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/**
* Value object representing the chief editor agent's review of generated content.
*
* Triggered by: Chief_Editor creates these; Publisher consumes them.
* Dependencies: None — pure data object.
*
* @see core/class-chief-editor.php — Creates these after reviewing content.
* @see core/class-publisher.php — Uses verdict to decide publish vs. draft.
*/
class Autoblogger_Editorial_Review {
private string $verdict;
private string $notes;
private ?string $revised_content;
private float $quality_score;
private float $seo_score;
private array $issues;
/**
* @param array{
* verdict: string,
* notes: string,
* revised_content?: string|null,
* quality_score?: float,
* seo_score?: float,
* issues?: string[],
* } $data
*/
public function __construct( array $data ) {
$this->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;
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
/**
* Value object for an API call log entry (cost tracking).
*
* Triggered by: Cost_Tracker creates these; Metrics_Page displays them.
* Dependencies: None — pure data object.
*
* @see core/class-cost-tracker.php — Writes these to ab_generation_log table.
* @see admin/class-metrics-page.php — Reads and displays these.
*/
class Autoblogger_Generation_Log {
private int $id;
private ?int $post_id;
private ?string $run_id;
private string $stage;
private string $provider;
private string $model;
private int $prompt_tokens;
private int $completion_tokens;
private float $estimated_cost;
private ?string $request_json;
private string $response_status;
private ?string $error_message;
private string $created_at;
/**
* @param array<string, mixed> $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<string, mixed>
*/
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,
];
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
/**
* Value object representing a single piece of collected social media data
* (a Reddit post, a TikTok comment, etc.).
*
* Triggered by: Source providers create these; Content_Analyzer consumes them.
* Dependencies: None — pure data object.
*
* @see providers/interface-source-provider.php — Providers return arrays of these.
* @see core/class-content-analyzer.php — Consumes these for pattern detection.
*/
class Autoblogger_Source_Data {
private int $id;
private string $source_type;
private string $source_id;
private ?string $subreddit;
private ?string $title;
private ?string $content;
private ?string $author;
private int $score;
private int $comment_count;
private ?string $permalink;
private string $collected_at;
private ?array $metadata;
/**
* @param array{
* id?: int,
* source_type: string,
* source_id: string,
* subreddit?: string|null,
* title?: string|null,
* content?: string|null,
* author?: string|null,
* score?: int,
* comment_count?: int,
* permalink?: string|null,
* collected_at?: string,
* metadata?: array|null,
* } $data
*/
public function __construct( array $data ) {
$this->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<string, mixed>
*/
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,
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/**
* Instagram source provider — STUB for future implementation.
*
* Will integrate with Instagram's Graph API to collect post captions,
* comments, and hashtag trends. Requires Meta developer app credentials.
*
* Triggered by: Source_Collector when 'instagram' is in enabled_sources.
* Dependencies: TBD — Instagram Graph API credentials.
*
* @see interface-source-provider.php — Interface this class implements.
*/
class Autoblogger_Instagram_Provider implements Autoblogger_Source_Provider_Interface {
/**
* @param array<string, mixed> $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' => '' ];
}
}

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
/**
* OpenRouter model pricing and availability lookup.
*
* Manages pricing tables, model lists, and cost estimation. Decoupled from
* the request logic for easy updates and testing.
*
* Triggered by: Autoblogger_OpenRouter_Provider calls pricing methods.
* Dependencies: None — pure data + calculation, no external calls by default.
*
* @see providers/class-openrouter-provider.php — Instantiates this class.
* @see ARCHITECTURE.md — Model selection flow.
*/
class Autoblogger_OpenRouter_Pricing {
/**
* Known model pricing (per million tokens, in USD).
* Updated periodically. Falls back to API-reported pricing if available.
*
* @var array<string, array{prompt: float, completion: float}>
*/
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<int, array{id: string, name: string, context_length: int, pricing: array{prompt: float, completion: float}}>
*/
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 );
}
}

View File

@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
/**
* OpenRouter API integration for all LLM calls (analysis, writing, editing).
*
* OpenRouter provides a unified API to access models from Anthropic, OpenAI,
* Google, Meta, and others. This lets users pick any model without us maintaining
* separate provider integrations.
*
* Triggered by: Content_Analyzer, Content_Generator, Chief_Editor, Metrics_Collector.
* Dependencies: Autoblogger_Encryption (for API key decryption), wp_remote_post(), Autoblogger_OpenRouter_Pricing.
*
* @see interface-llm-provider.php — Interface this class implements.
* @see class-openrouter-pricing.php — Pricing and model list lookups.
* @see class-cost-tracker.php — Called after every request to log token usage.
* @see ARCHITECTURE.md — Data flow diagram showing where this fits.
*/
class Autoblogger_OpenRouter_Provider implements Autoblogger_LLM_Provider_Interface {
private const API_BASE_URL = 'https://openrouter.ai/api/v1';
/**
* Send a chat completion request to OpenRouter.
*
* Retries with exponential backoff on transient failures (5xx, timeout).
* Logs every attempt. Fails loudly after exhausting retries.
*
* Side effects: HTTP request to OpenRouter API.
*
* @param array<int, array{role: string, content: string}> $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<int, array{id: string, name: string, context_length: int, pricing: array{prompt: float, completion: float}}>
*/
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 );
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
/**
* Reddit API HTTP client for authenticated requests.
*
* Encapsulates OAuth2 token management, rate limit tracking, and HTTP methods
* for the Reddit API. Decoupled from collection logic for testability.
*
* Triggered by: Autoblogger_Reddit_Provider calls fetch methods here.
* Dependencies: Autoblogger_Encryption (for client secret), wp_remote_post/get().
*
* @see providers/class-reddit-provider.php — Instantiates this class.
* @see ARCHITECTURE.md — External API integrations table.
*/
class Autoblogger_Reddit_API_Client {
private const AUTH_URL = 'https://www.reddit.com/api/v1/access_token';
private const API_BASE = 'https://oauth.reddit.com';
private const USER_AGENT = 'AutoBlogger/0.1.0 (WordPress Plugin)';
/**
* Get an OAuth2 access token, using cached transient if available.
*
* Reddit script apps use client_credentials grant type.
*
* Side effects: HTTP request, sets transient.
*
* @param bool $force_refresh Bypass cache and request a new token.
*
* @return string Access token, or empty string on failure.
*/
public function get_access_token( bool $force_refresh = false ): string {
if ( ! $force_refresh ) {
$cached = get_transient( 'autoblogger_reddit_token' );
if ( false !== $cached && is_string( $cached ) ) {
return $cached;
}
}
$client_id = get_option( 'autoblogger_reddit_client_id', '' );
$client_secret = Autoblogger_Encryption::decrypt(
get_option( 'autoblogger_reddit_client_secret', '' )
);
if ( '' === $client_id || '' === $client_secret ) {
return '';
}
$response = wp_remote_post(
self::AUTH_URL,
[
'timeout' => 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<int, array<string, mixed>> 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<int, array<string, mixed>> 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<string, mixed>|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 );
}
}

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
/**
* Reddit API integration for collecting posts and comments from target subreddits.
*
* Uses Reddit's OAuth2 "script" app type for server-to-server authentication.
* Collects hot/top posts and their top comments from configured subreddits,
* storing them in the ab_source_data table.
*
* Triggered by: Source_Collector calls collect_data() on each enabled provider.
* Dependencies: Autoblogger_Reddit_API_Client (for HTTP calls and auth).
*
* @see interface-source-provider.php — Interface this class implements.
* @see providers/class-reddit-api-client.php — HTTP client for Reddit API.
* @see core/class-source-collector.php — Orchestrates collection from all providers.
* @see ARCHITECTURE.md — External API integrations table.
*/
class Autoblogger_Reddit_Provider implements Autoblogger_Source_Provider_Interface {
/**
* Collect posts and comments from configured subreddits.
*
* For each subreddit: fetches hot posts, then fetches top comments for
* the most-engaged posts. Returns an array of Source_Data objects.
*
* Side effects: HTTP requests to Reddit API, uses/sets transients for OAuth token.
*
* @param array{
* subreddits?: string[],
* limit?: int,
* time_filter?: string,
* include_comments?: bool,
* comments_per_post?: int,
* } $config Collection configuration.
*
* @return Autoblogger_Source_Data[]
*
* @throws \RuntimeException On authentication failure or API error.
*/
public function collect_data( array $config ): array {
$api_client = new Autoblogger_Reddit_API_Client();
$token = $api_client->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 : [];
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
/**
* TikTok source provider — STUB for future implementation.
*
* Will integrate with TikTok's API to collect video descriptions, comments,
* and trending topics. Requires TikTok developer app credentials.
*
* Triggered by: Source_Collector when 'tiktok' is in enabled_sources.
* Dependencies: TBD — TikTok API credentials.
*
* @see interface-source-provider.php — Interface this class implements.
* @see ARCHITECTURE.md — Planned source providers.
*/
class Autoblogger_Tiktok_Provider implements Autoblogger_Source_Provider_Interface {
/**
* @param array<string, mixed> $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' => '' ];
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/**
* YouTube source provider — STUB for future implementation.
*
* Will integrate with YouTube Data API v3 to collect video titles,
* descriptions, comments, and trending topics. Requires Google API key.
*
* Triggered by: Source_Collector when 'youtube' is in enabled_sources.
* Dependencies: TBD — YouTube Data API key.
*
* @see interface-source-provider.php — Interface this class implements.
*/
class Autoblogger_Youtube_Provider implements Autoblogger_Source_Provider_Interface {
/**
* @param array<string, mixed> $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' => '' ];
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
/**
* Contract for any LLM provider integration (OpenRouter, direct Anthropic, OpenAI, etc.).
*
* Any class implementing this interface can be swapped in without changing the
* content generation, analysis, or editor logic.
*
* @see class-openrouter-provider.php — Primary implementation (OpenRouter).
* @see CONVENTIONS.md — "How To: Add a New LLM Provider".
* @see ARCHITECTURE.md — External API integrations table.
*/
interface Autoblogger_LLM_Provider_Interface {
/**
* Send a chat completion request to the LLM.
*
* @param array<int, array{role: string, content: string}> $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<int, array{id: string, name: string, context_length: int, pricing: array{prompt: float, completion: float}}> 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;
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/**
* Contract for any social media source provider (Reddit, TikTok, Instagram, YouTube).
*
* Any class implementing this interface can be registered as a data source.
* The Source_Collector iterates through enabled providers and calls collect_data().
*
* @see class-reddit-provider.php — Primary implementation (Reddit API).
* @see core/class-source-collector.php — Orchestrates collection from all providers.
* @see CONVENTIONS.md — "How To: Add a New Source Provider".
*/
interface Autoblogger_Source_Provider_Interface {
/**
* Collect posts/comments from this social platform.
*
* @param array{
* subreddits?: string[],
* limit?: int,
* time_filter?: string,
* keywords?: string[],
* } $config Source-specific configuration.
*
* @return Autoblogger_Source_Data[] Array of collected data objects.
*
* @throws \RuntimeException On API error after retries exhausted.
*/
public function collect_data( array $config ): array;
/**
* Get the source type identifier for this provider.
*
* Must match a valid value for ab_source_data.source_type.
*
* @return string Source type (e.g., 'reddit', 'tiktok', 'youtube').
*/
public function get_source_type(): string;
/**
* Validate that the provider's API credentials are configured and working.
*
* @return bool True if credentials are valid and API is reachable.
*/
public function validate_credentials(): bool;
/**
* Get current rate limit status for this provider's API.
*
* @return array{
* remaining: int,
* limit: int,
* resets_at: string,
* } Rate limit information.
*/
public function get_rate_limit_status(): array;
}

24
phpunit.xml.dist Executable file
View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
beStrictAboutTestsThatDoNotTestAnything="true"
beStrictAboutOutputDuringTests="true"
convertDeprecationsToExceptions="true"
>
<testsuites>
<testsuite name="unit">
<directory suffix="Test.php">tests/unit</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="false">
<include>
<directory suffix=".php">includes/core</directory>
<directory suffix=".php">includes/models</directory>
<directory suffix=".php">includes/providers</directory>
</include>
</coverage>
</phpunit>

58
readme.txt Executable file
View File

@@ -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.

160
scripts/deploy.sh Executable file
View File

@@ -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."

134
templates/admin/log-viewer.php Executable file
View File

@@ -0,0 +1,134 @@
<?php
/**
* Log viewer template — displays structured application log entries.
*
* Variables from Autoblogger_Log_Viewer::render_page():
* $rows — array of log entry rows.
* $total — total matching entries.
* $total_pages — total pages.
* $level — current level filter.
* $search — current search string.
* $paged — current page number.
*
* @see admin/class-log-viewer.php — Renders this template.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$base_url = admin_url( 'admin.php?page=autoblogger-logs' );
$level_counts = [
'all' => __( 'All', 'autoblogger' ),
'error' => __( 'Errors', 'autoblogger' ),
'warning' => __( 'Warnings', 'autoblogger' ),
'info' => __( 'Info', 'autoblogger' ),
'debug' => __( 'Debug', 'autoblogger' ),
];
?>
<div class="wrap ab-wrap">
<div class="ab-header">
<div class="ab-header-left">
<span class="dashicons dashicons-list-view ab-header-icon"></span>
<h1 class="ab-header-title"><?php esc_html_e( 'Activity Log', 'autoblogger' ); ?></h1>
</div>
<div class="ab-header-actions">
<button type="button" id="autoblogger-clear-logs" class="ab-btn ab-btn-outline" data-nonce="<?php echo esc_attr( wp_create_nonce( 'autoblogger_clear_logs' ) ); ?>">
<span class="dashicons dashicons-trash"></span>
<?php esc_html_e( 'Clear Logs (30d+)', 'autoblogger' ); ?>
</button>
</div>
</div>
<div id="autoblogger-log-status" class="hidden"></div>
<!-- Filters -->
<div class="ab-log-filters">
<div class="ab-log-level-tabs">
<?php foreach ( $level_counts as $lv => $lv_label ) : ?>
<a href="<?php echo esc_url( add_query_arg( [ 'level' => $lv, 's' => $search, 'paged' => 1 ], $base_url ) ); ?>"
class="ab-log-level-tab <?php echo $level === $lv ? 'ab-log-level-active' : ''; ?>">
<?php echo esc_html( $lv_label ); ?>
</a>
<?php endforeach; ?>
</div>
<form method="get" action="<?php echo esc_url( $base_url ); ?>" class="ab-log-search">
<input type="hidden" name="page" value="autoblogger-logs" />
<input type="hidden" name="level" value="<?php echo esc_attr( $level ); ?>" />
<input type="text" name="s" value="<?php echo esc_attr( $search ); ?>" placeholder="<?php esc_attr_e( 'Search logs...', 'autoblogger' ); ?>" class="ab-input ab-log-search-input" />
<button type="submit" class="ab-btn ab-btn-outline ab-btn-small"><?php esc_html_e( 'Search', 'autoblogger' ); ?></button>
</form>
</div>
<!-- Results count -->
<p class="ab-log-count">
<?php
printf(
/* translators: %s: total count */
esc_html__( '%s entries found', 'autoblogger' ),
'<strong>' . esc_html( number_format( $total ) ) . '</strong>'
);
?>
</p>
<?php if ( empty( $rows ) ) : ?>
<div class="ab-log-empty">
<span class="dashicons dashicons-yes-alt"></span>
<p><?php esc_html_e( 'No log entries match your filters.', 'autoblogger' ); ?></p>
</div>
<?php else : ?>
<table class="widefat striped ab-log-table">
<thead>
<tr>
<th class="ab-log-col-level"><?php esc_html_e( 'Level', 'autoblogger' ); ?></th>
<th class="ab-log-col-time"><?php esc_html_e( 'Time', 'autoblogger' ); ?></th>
<th class="ab-log-col-context"><?php esc_html_e( 'Context', 'autoblogger' ); ?></th>
<th><?php esc_html_e( 'Message', 'autoblogger' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $rows as $row ) : ?>
<?php $lvl = $row['level'] ?? 'info'; ?>
<tr class="ab-log-row ab-log-row-<?php echo esc_attr( $lvl ); ?>">
<td class="ab-log-col-level">
<span class="ab-log-badge ab-log-badge-<?php echo esc_attr( $lvl ); ?>">
<?php echo esc_html( strtoupper( $lvl ) ); ?>
</span>
</td>
<td class="ab-log-col-time">
<?php echo esc_html( wp_date( 'M j, g:i:s A', strtotime( $row['created_at'] ) ) ); ?>
</td>
<td class="ab-log-col-context">
<?php echo esc_html( $row['context'] ?? '' ); ?>
</td>
<td>
<?php echo esc_html( $row['message'] ?? '' ); ?>
<?php if ( ! empty( $row['meta_json'] ) ) : ?>
<button type="button" class="ab-log-meta-toggle" title="<?php esc_attr_e( 'Show details', 'autoblogger' ); ?>">
<span class="dashicons dashicons-arrow-down-alt2"></span>
</button>
<pre class="ab-log-meta"><?php echo esc_html( wp_json_encode( json_decode( $row['meta_json'], true ), JSON_PRETTY_PRINT ) ); ?></pre>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if ( $total_pages > 1 ) : ?>
<div class="ab-log-pagination">
<?php
echo wp_kses_post( paginate_links( [
'base' => add_query_arg( 'paged', '%#%' ),
'format' => '',
'current' => $paged,
'total' => $total_pages,
'prev_text' => '&laquo;',
'next_text' => '&raquo;',
] ) );
?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,75 @@
<?php
/**
* Post metabox template showing AutoBlogger generation metadata.
*
* @see admin/class-post-metabox.php — Renders this template.
*
* @var WP_Post $post The current post.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$meta = [
'topic' => 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 ),
];
?>
<div class="autoblogger-metabox">
<p>
<strong><?php esc_html_e( 'Generated:', 'autoblogger' ); ?></strong>
<?php echo esc_html( $meta['generated_at'] ?: __( 'Unknown', 'autoblogger' ) ); ?>
</p>
<p>
<strong><?php esc_html_e( 'Topic:', 'autoblogger' ); ?></strong>
<?php echo esc_html( $meta['topic'] ?: '—' ); ?>
</p>
<p>
<strong><?php esc_html_e( 'Type:', 'autoblogger' ); ?></strong>
<?php echo esc_html( ucfirst( $meta['type'] ?: 'article' ) ); ?>
</p>
<p>
<strong><?php esc_html_e( 'Model:', 'autoblogger' ); ?></strong>
<?php echo esc_html( $meta['model'] ?: '—' ); ?>
</p>
<p>
<strong><?php esc_html_e( 'Pipeline:', 'autoblogger' ); ?></strong>
<?php echo esc_html( 'multi_step' === $meta['pipeline'] ? __( 'Multi-step', 'autoblogger' ) : __( 'Single-pass', 'autoblogger' ) ); ?>
</p>
<p>
<strong><?php esc_html_e( 'Editor Verdict:', 'autoblogger' ); ?></strong>
<span class="autoblogger-verdict-<?php echo esc_attr( $meta['verdict'] ?: 'unknown' ); ?>">
<?php echo esc_html( ucfirst( $meta['verdict'] ?: __( 'Unknown', 'autoblogger' ) ) ); ?>
</span>
</p>
<?php if ( $meta['quality_score'] ) : ?>
<p>
<strong><?php esc_html_e( 'Quality:', 'autoblogger' ); ?></strong>
<?php echo esc_html( number_format( (float) $meta['quality_score'] * 100, 0 ) ); ?>%
&nbsp;|&nbsp;
<strong><?php esc_html_e( 'SEO:', 'autoblogger' ); ?></strong>
<?php echo esc_html( number_format( (float) $meta['seo_score'] * 100, 0 ) ); ?>%
</p>
<?php endif; ?>
<?php if ( $meta['editor_notes'] ) : ?>
<p>
<strong><?php esc_html_e( 'Editor Notes:', 'autoblogger' ); ?></strong><br>
<?php echo esc_html( $meta['editor_notes'] ); ?>
</p>
<?php endif; ?>
<?php if ( $meta['keywords'] ) : ?>
<p>
<strong><?php esc_html_e( 'Keywords:', 'autoblogger' ); ?></strong>
<?php echo esc_html( implode( ', ', json_decode( $meta['keywords'], true ) ?: [] ) ); ?>
</p>
<?php endif; ?>
</div>

115
templates/admin/metrics-page.php Executable file
View File

@@ -0,0 +1,115 @@
<?php
/**
* Metrics & cost dashboard template.
*
* @see admin/class-metrics-page.php — Renders this template.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$cost_tracker = new Autoblogger_Cost_Tracker();
$monthly_spend = $cost_tracker->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 );
?>
<div class="wrap autoblogger-metrics">
<h1><?php esc_html_e( 'AutoBlogger — Metrics & Costs', 'autoblogger' ); ?></h1>
<!-- Budget Overview Cards -->
<div class="autoblogger-cards">
<div class="autoblogger-card">
<h3><?php esc_html_e( 'Monthly Spend', 'autoblogger' ); ?></h3>
<div class="autoblogger-card-value">
$<?php echo esc_html( number_format( $monthly_spend, 4 ) ); ?>
</div>
<div class="autoblogger-card-sub">
<?php
printf(
/* translators: %s: budget amount */
esc_html__( 'of $%s budget', 'autoblogger' ),
esc_html( number_format( $budget, 2 ) )
);
?>
</div>
</div>
<div class="autoblogger-card <?php echo $utilization >= 100 ? 'autoblogger-card-danger' : ( $utilization >= 80 ? 'autoblogger-card-warning' : '' ); ?>">
<h3><?php esc_html_e( 'Budget Used', 'autoblogger' ); ?></h3>
<div class="autoblogger-card-value">
<?php echo esc_html( number_format( $utilization, 1 ) ); ?>%
</div>
<div class="autoblogger-budget-bar">
<div class="autoblogger-budget-fill" style="width: <?php echo esc_attr( (string) min( 100, $utilization ) ); ?>%"></div>
</div>
</div>
<div class="autoblogger-card">
<h3><?php esc_html_e( 'Next Generation', 'autoblogger' ); ?></h3>
<div class="autoblogger-card-value autoblogger-card-value-small">
<?php
$next_run = wp_next_scheduled( 'autoblogger_daily_generation' );
echo false !== $next_run
? esc_html( wp_date( 'M j, g:i A', $next_run ) )
: esc_html__( 'Not scheduled', 'autoblogger' );
?>
</div>
</div>
</div>
<!-- Cost by Stage -->
<h2><?php esc_html_e( 'Cost by Pipeline Stage (This Month)', 'autoblogger' ); ?></h2>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e( 'Stage', 'autoblogger' ); ?></th>
<th><?php esc_html_e( 'API Calls', 'autoblogger' ); ?></th>
<th><?php esc_html_e( 'Total Tokens', 'autoblogger' ); ?></th>
<th><?php esc_html_e( 'Cost (USD)', 'autoblogger' ); ?></th>
</tr>
</thead>
<tbody>
<?php if ( empty( $stage_breakdown ) ) : ?>
<tr><td colspan="4"><?php esc_html_e( 'No data yet.', 'autoblogger' ); ?></td></tr>
<?php else : ?>
<?php foreach ( $stage_breakdown as $stage => $data ) : ?>
<tr>
<td><?php echo esc_html( ucfirst( $stage ) ); ?></td>
<td><?php echo esc_html( (string) $data['calls'] ); ?></td>
<td><?php echo esc_html( number_format( $data['tokens'] ) ); ?></td>
<td>$<?php echo esc_html( number_format( $data['cost'], 4 ) ); ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<!-- Daily Spend Chart (simple HTML table — upgrade to Chart.js later) -->
<h2><?php esc_html_e( 'Daily Spend (Last 30 Days)', 'autoblogger' ); ?></h2>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e( 'Date', 'autoblogger' ); ?></th>
<th><?php esc_html_e( 'Cost (USD)', 'autoblogger' ); ?></th>
</tr>
</thead>
<tbody>
<?php if ( empty( $daily_spend ) ) : ?>
<tr><td colspan="2"><?php esc_html_e( 'No data yet.', 'autoblogger' ); ?></td></tr>
<?php else : ?>
<?php foreach ( array_reverse( $daily_spend, true ) as $date => $cost ) : ?>
<tr>
<td><?php echo esc_html( $date ); ?></td>
<td>$<?php echo esc_html( number_format( $cost, 4 ) ); ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>

154
templates/admin/review-queue.php Executable file
View File

@@ -0,0 +1,154 @@
<?php
/**
* Review queue template — lists generated drafts awaiting editorial approval.
*
* Variables available from Autoblogger_Review_Queue::render_page():
* $query (WP_Query) — the pending draft posts.
*
* @see admin/class-review-queue.php — Renders this template.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$bulk_done = isset( $_GET['autoblogger_bulk_done'] ) ? absint( $_GET['autoblogger_bulk_done'] ) : 0;
$bulk_type = isset( $_GET['autoblogger_bulk_type'] ) ? sanitize_text_field( wp_unslash( $_GET['autoblogger_bulk_type'] ) ) : '';
?>
<div class="wrap autoblogger-review-queue">
<h1><?php esc_html_e( 'AutoBlogger — Review Queue', 'autoblogger' ); ?></h1>
<?php if ( $bulk_done > 0 && '' !== $bulk_type ) : ?>
<div class="notice notice-success is-dismissible">
<p>
<?php
if ( 'approve' === $bulk_type ) {
printf(
/* translators: %d: number of posts */
esc_html( _n( '%d post published.', '%d posts published.', $bulk_done, 'autoblogger' ) ),
$bulk_done
);
} else {
printf(
/* translators: %d: number of posts */
esc_html( _n( '%d post rejected.', '%d posts rejected.', $bulk_done, 'autoblogger' ) ),
$bulk_done
);
}
?>
</p>
</div>
<?php endif; ?>
<div id="autoblogger-queue-status" class="autoblogger-status-message hidden"></div>
<?php if ( ! $query->have_posts() ) : ?>
<div class="autoblogger-empty-queue">
<p><?php esc_html_e( 'No generated drafts awaiting review. New posts will appear here after the next generation run.', 'autoblogger' ); ?></p>
</div>
<?php else : ?>
<form method="post" id="autoblogger-review-form">
<?php wp_nonce_field( 'autoblogger_review_queue_bulk', 'autoblogger_review_nonce' ); ?>
<div class="autoblogger-queue-toolbar">
<label>
<input type="checkbox" id="autoblogger-select-all" />
<?php esc_html_e( 'Select All', 'autoblogger' ); ?>
</label>
<button type="submit" name="autoblogger_bulk_action" value="approve" class="button button-primary">
<?php esc_html_e( 'Approve Selected', 'autoblogger' ); ?>
</button>
<button type="submit" name="autoblogger_bulk_action" value="reject" class="button">
<?php esc_html_e( 'Reject Selected', 'autoblogger' ); ?>
</button>
<span class="autoblogger-queue-count">
<?php
printf(
/* translators: %d: number of drafts */
esc_html( _n( '%d draft', '%d drafts', $query->found_posts, 'autoblogger' ) ),
$query->found_posts
);
?>
</span>
</div>
<table class="widefat striped autoblogger-queue-table">
<thead>
<tr>
<th class="check-column"><span class="screen-reader-text"><?php esc_html_e( 'Select', 'autoblogger' ); ?></span></th>
<th><?php esc_html_e( 'Title', 'autoblogger' ); ?></th>
<th><?php esc_html_e( 'Topic', 'autoblogger' ); ?></th>
<th><?php esc_html_e( 'Type', 'autoblogger' ); ?></th>
<th><?php esc_html_e( 'Editor Verdict', 'autoblogger' ); ?></th>
<th><?php esc_html_e( 'Quality', 'autoblogger' ); ?></th>
<th><?php esc_html_e( 'Generated', 'autoblogger' ); ?></th>
<th><?php esc_html_e( 'Actions', 'autoblogger' ); ?></th>
</tr>
</thead>
<tbody>
<?php while ( $query->have_posts() ) : $query->the_post(); ?>
<?php
$post_id = get_the_ID();
$verdict = get_post_meta( $post_id, '_autoblogger_editor_verdict', true );
$topic = get_post_meta( $post_id, '_autoblogger_topic', true );
$article_type = get_post_meta( $post_id, '_autoblogger_article_type', true );
$quality_score = get_post_meta( $post_id, '_autoblogger_quality_score', true );
$generated_at = get_post_meta( $post_id, '_autoblogger_generated_at', true );
$editor_notes = get_post_meta( $post_id, '_autoblogger_editor_notes', true );
$verdict_class = 'autoblogger-verdict-' . sanitize_html_class( $verdict ?: 'pending' );
?>
<tr data-post-id="<?php echo esc_attr( (string) $post_id ); ?>">
<td class="check-column">
<input type="checkbox" name="autoblogger_post_ids[]" value="<?php echo esc_attr( (string) $post_id ); ?>" />
</td>
<td>
<strong><?php the_title(); ?></strong>
<?php if ( $editor_notes ) : ?>
<div class="autoblogger-editor-notes"><?php echo esc_html( $editor_notes ); ?></div>
<?php endif; ?>
</td>
<td><?php echo esc_html( $topic ?: '—' ); ?></td>
<td><?php echo esc_html( ucfirst( $article_type ?: 'article' ) ); ?></td>
<td><span class="<?php echo esc_attr( $verdict_class ); ?>"><?php echo esc_html( ucfirst( $verdict ?: 'pending' ) ); ?></span></td>
<td><?php echo '' !== $quality_score ? esc_html( $quality_score . '/10' ) : '—'; ?></td>
<td><?php echo $generated_at ? esc_html( wp_date( 'M j, g:i A', strtotime( $generated_at ) ) ) : '—'; ?></td>
<td class="autoblogger-queue-actions">
<button type="button" class="button button-small button-primary autoblogger-approve-btn" data-post-id="<?php echo esc_attr( (string) $post_id ); ?>">
<?php esc_html_e( 'Approve', 'autoblogger' ); ?>
</button>
<a href="<?php echo esc_url( get_edit_post_link( $post_id ) ); ?>" class="button button-small">
<?php esc_html_e( 'Edit', 'autoblogger' ); ?>
</a>
<a href="<?php echo esc_url( get_preview_post_link( $post_id ) ); ?>" class="button button-small" target="_blank">
<?php esc_html_e( 'Preview', 'autoblogger' ); ?>
</a>
<button type="button" class="button button-small autoblogger-reject-btn" data-post-id="<?php echo esc_attr( (string) $post_id ); ?>">
<?php esc_html_e( 'Reject', 'autoblogger' ); ?>
</button>
</td>
</tr>
<?php endwhile; ?>
<?php wp_reset_postdata(); ?>
</tbody>
</table>
</form>
<?php
// Pagination.
$total_pages = $query->max_num_pages;
if ( $total_pages > 1 ) :
$current_page = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
echo '<div class="autoblogger-pagination">';
echo wp_kses_post( paginate_links( [
'base' => add_query_arg( 'paged', '%#%' ),
'format' => '',
'current' => $current_page,
'total' => $total_pages,
'prev_text' => '&laquo;',
'next_text' => '&raquo;',
] ) );
echo '</div>';
endif;
?>
<?php endif; ?>
</div>

111
templates/admin/settings-page.php Executable file
View File

@@ -0,0 +1,111 @@
<?php
/**
* Settings page template — modern tabbed UI.
*
* Variables from Autoblogger_Admin_Page::render_page():
* $sections — tab definitions from Settings_Fields::get_sections().
* $fields — all field definitions (unused here, WP renders via do_settings_fields).
*
* @see admin/class-admin-page.php — Renders this template.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$active_tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'autoblogger_api';
$cost_tracker = new Autoblogger_Cost_Tracker();
$monthly_spend = $cost_tracker->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' );
?>
<div class="wrap ab-wrap">
<!-- Header -->
<div class="ab-header">
<div class="ab-header-left">
<span class="dashicons dashicons-edit-page ab-header-icon"></span>
<div>
<h1 class="ab-header-title"><?php esc_html_e( 'AutoBlogger', 'autoblogger' ); ?></h1>
<span class="ab-header-version">v<?php echo esc_html( AUTOBLOGGER_VERSION ); ?></span>
</div>
</div>
<div class="ab-header-actions">
<button type="button" id="autoblogger-test-connection" class="ab-btn ab-btn-outline">
<span class="dashicons dashicons-yes-alt"></span>
<?php esc_html_e( 'Test Connections', 'autoblogger' ); ?>
</button>
<button type="button" id="autoblogger-generate-now" class="ab-btn ab-btn-primary">
<span class="dashicons dashicons-update"></span>
<?php esc_html_e( 'Generate Now', 'autoblogger' ); ?>
</button>
</div>
</div>
<!-- Quick stats bar -->
<div class="ab-stats-bar">
<div class="ab-stat">
<span class="ab-stat-label"><?php esc_html_e( 'Monthly Spend', 'autoblogger' ); ?></span>
<span class="ab-stat-value <?php echo $utilization >= 90 ? 'ab-text-danger' : ( $utilization >= 70 ? 'ab-text-warning' : '' ); ?>">
$<?php echo esc_html( number_format( $monthly_spend, 2 ) ); ?>
<small>/ $<?php echo esc_html( number_format( $budget, 2 ) ); ?></small>
</span>
</div>
<div class="ab-stat">
<span class="ab-stat-label"><?php esc_html_e( 'Next Run', 'autoblogger' ); ?></span>
<span class="ab-stat-value">
<?php echo false !== $next_run ? esc_html( wp_date( 'M j, g:i A', $next_run ) ) : '<em>' . esc_html__( 'Not scheduled', 'autoblogger' ) . '</em>'; ?>
</span>
</div>
<div class="ab-stat">
<span class="ab-stat-label"><?php esc_html_e( 'Review Queue', 'autoblogger' ); ?></span>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=autoblogger-review-queue' ) ); ?>" class="ab-stat-value ab-stat-link">
<?php esc_html_e( 'View Drafts →', 'autoblogger' ); ?>
</a>
</div>
</div>
<div id="autoblogger-status-message" class="hidden"></div>
<!-- Tab navigation + settings form -->
<div class="ab-layout">
<nav class="ab-tabs" role="tablist">
<?php foreach ( $sections as $sid => $sec ) : ?>
<a href="#"
class="ab-tab <?php echo $active_tab === $sid ? 'ab-tab-active' : ''; ?>"
data-tab="<?php echo esc_attr( $sid ); ?>"
role="tab">
<span class="dashicons <?php echo esc_attr( $sec['icon'] ?? 'dashicons-admin-generic' ); ?>"></span>
<span class="ab-tab-label"><?php echo esc_html( $sec['title'] ); ?></span>
</a>
<?php endforeach; ?>
</nav>
<div class="ab-content">
<form method="post" action="options.php" id="ab-settings-form">
<?php settings_fields( 'autoblogger_settings_group' ); ?>
<?php foreach ( $sections as $sid => $sec ) : ?>
<div class="ab-panel <?php echo $active_tab === $sid ? 'ab-panel-active' : ''; ?>"
data-tab="<?php echo esc_attr( $sid ); ?>"
role="tabpanel">
<div class="ab-panel-head">
<h2 class="ab-panel-title"><?php echo esc_html( $sec['title'] ); ?></h2>
<?php if ( ! empty( $sec['description'] ) ) : ?>
<p class="ab-panel-desc"><?php echo esc_html( $sec['description'] ); ?></p>
<?php endif; ?>
</div>
<table class="form-table ab-form-table" role="presentation">
<?php do_settings_fields( 'autoblogger-settings', $sid ); ?>
</table>
</div>
<?php endforeach; ?>
<div class="ab-save-bar">
<?php submit_button( __( 'Save Settings', 'autoblogger' ), 'ab-btn ab-btn-primary', 'submit', false ); ?>
</div>
</form>
</div>
</div>
</div>

65
tests/bootstrap.php Executable file
View File

@@ -0,0 +1,65 @@
<?php
/**
* PHPUnit bootstrap for AutoBlogger unit tests.
*
* Uses Brain Monkey to mock WordPress functions so tests run without
* a full WordPress installation. Only loads the classes under test.
*
* @see https://brain-wp.github.io/BrainMonkey/
*/
declare(strict_types=1);
// Composer autoloader for Brain Monkey and test dependencies.
$autoloader = __DIR__ . '/../vendor/autoload.php';
if ( ! file_exists( $autoloader ) ) {
echo "Run 'composer install' before running tests.\n";
exit( 1 );
}
require_once $autoloader;
// Define WordPress constants that plugin code expects.
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', '/tmp/fake-wp/' );
}
if ( ! defined( 'DAY_IN_SECONDS' ) ) {
define( 'DAY_IN_SECONDS', 86400 );
}
if ( ! defined( 'HOUR_IN_SECONDS' ) ) {
define( 'HOUR_IN_SECONDS', 3600 );
}
// AutoBlogger constants from the main plugin file.
if ( ! defined( 'AUTOBLOGGER_VERSION' ) ) {
define( 'AUTOBLOGGER_VERSION', '0.1.0' );
}
if ( ! defined( 'AUTOBLOGGER_MAX_RETRIES' ) ) {
define( 'AUTOBLOGGER_MAX_RETRIES', 3 );
}
if ( ! defined( 'AUTOBLOGGER_RETRY_BASE_DELAY_SECONDS' ) ) {
define( 'AUTOBLOGGER_RETRY_BASE_DELAY_SECONDS', 2 );
}
if ( ! defined( 'AUTOBLOGGER_DEFAULT_ANALYSIS_MODEL' ) ) {
define( 'AUTOBLOGGER_DEFAULT_ANALYSIS_MODEL', 'anthropic/claude-3.5-haiku' );
}
if ( ! defined( 'AUTOBLOGGER_DEFAULT_WRITING_MODEL' ) ) {
define( 'AUTOBLOGGER_DEFAULT_WRITING_MODEL', 'anthropic/claude-sonnet-4' );
}
if ( ! defined( 'AUTOBLOGGER_DEFAULT_EDITOR_MODEL' ) ) {
define( 'AUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'anthropic/claude-sonnet-4' );
}
// Load plugin class files (no autoloader in tests — explicit includes).
$includes_dir = dirname( __DIR__ ) . '/includes';
// Models (no WP dependencies, safe to load).
require_once $includes_dir . '/models/class-generation-log.php';
require_once $includes_dir . '/models/class-content-score.php';
require_once $includes_dir . '/models/class-source-data.php';
require_once $includes_dir . '/models/class-analysis-result.php';
require_once $includes_dir . '/models/class-article-idea.php';
require_once $includes_dir . '/models/class-editorial-review.php';
require_once $includes_dir . '/models/class-content-request.php';
// Providers (loaded on demand in tests since they need WP mocks).
// Core classes (loaded on demand in tests since they need WP mocks).

65
tests/unit/ArticleIdeaTest.php Executable file
View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/**
* Unit tests for Autoblogger_Article_Idea value object.
*
* Article ideas flow from the scorer to the generator to the editor —
* data must survive the full pipeline without mutation or loss.
*/
class ArticleIdeaTest extends \PHPUnit\Framework\TestCase {
public function test_constructor_sets_all_fields(): void {
$data = [
'topic' => '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 );
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
/**
* Unit tests for Autoblogger_Editorial_Review value object.
*
* The editorial review verdict drives the publish/draft/reject decision.
* A null revised_content on a "revised" verdict caused a bug (now fixed) —
* these tests ensure the value object correctly handles edge cases.
*/
class EditorialReviewTest extends \PHPUnit\Framework\TestCase {
public function test_approved_review(): void {
$review = new Autoblogger_Editorial_Review( [
'verdict' => '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' => '<h1>Improved Article</h1><p>Better content.</p>',
'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() );
}
}

128
tests/unit/GenerationLogTest.php Executable file
View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
use Brain\Monkey;
use Brain\Monkey\Functions;
/**
* Unit tests for Autoblogger_Generation_Log value object.
*
* Verifies construction, getters, and to_db_row() serialization.
* These are the records that power the cost dashboard — data integrity is critical.
*/
class GenerationLogTest extends \PHPUnit\Framework\TestCase {
protected function setUp(): void {
parent::setUp();
Monkey\setUp();
// Mock current_time() which the constructor calls as default for created_at.
Functions\when( 'current_time' )->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() );
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
use Brain\Monkey;
use Brain\Monkey\Functions;
/**
* Unit tests for Autoblogger_OpenRouter_Pricing cost estimation.
*
* Cost calculation accuracy directly affects budget enforcement and the
* metrics dashboard. Getting this wrong means either overspending or
* blocking generation when there's still budget left.
*/
class OpenRouterPricingTest extends \PHPUnit\Framework\TestCase {
protected function setUp(): void {
parent::setUp();
Monkey\setUp();
// Mock WP functions used by the pricing class.
Functions\when( 'get_option' )->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.' );
}
}

90
uninstall.php Executable file
View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
/**
* Fired when the plugin is uninstalled (deleted) from WordPress.
*
* Removes ALL plugin data: custom tables, options, post meta, transients, and cron events.
* This is the nuclear option — only runs when the user explicitly deletes the plugin.
*
* @see class-deactivator.php — Lighter cleanup on deactivation (preserves data).
*/
// Abort if not called by WordPress uninstall process.
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
global $wpdb;
/*
|--------------------------------------------------------------------------
| 1. Drop Custom Tables
|--------------------------------------------------------------------------
*/
$prefix = $wpdb->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 );
}
}