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:
36
.distignore
Executable file
36
.distignore
Executable 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
|
||||||
236
.github/mu-plugins/autoblogger-deploy-receiver.php
vendored
Executable file
236
.github/mu-plugins/autoblogger-deploy-receiver.php
vendored
Executable 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
159
.github/workflows/deploy.yml
vendored
Executable 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
27
.gitignore
vendored
Executable 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
333
ARCHITECTURE.md
Executable 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
30
CHANGELOG.md
Executable 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
168
CONVENTIONS.md
Executable 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
273
assets/css/admin.css
Executable 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
275
assets/js/admin.js
Executable 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
98
autoblogger.php
Executable 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
19
composer.json
Executable 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/"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
96
includes/admin/class-admin-notices.php
Executable file
96
includes/admin/class-admin-notices.php
Executable 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' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
207
includes/admin/class-admin-page.php
Executable file
207
includes/admin/class-admin-page.php
Executable 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
67
includes/admin/class-dashboard-widget.php
Executable file
67
includes/admin/class-dashboard-widget.php
Executable 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' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
includes/admin/class-log-viewer.php
Executable file
84
includes/admin/class-log-viewer.php
Executable 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
|
||||||
|
),
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
}
|
||||||
46
includes/admin/class-metrics-page.php
Executable file
46
includes/admin/class-metrics-page.php
Executable 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
54
includes/admin/class-post-metabox.php
Executable file
54
includes/admin/class-post-metabox.php
Executable 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
203
includes/admin/class-review-queue.php
Executable file
203
includes/admin/class-review-queue.php
Executable 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 ] );
|
||||||
|
}
|
||||||
|
}
|
||||||
291
includes/admin/class-settings-fields.php
Executable file
291
includes/admin/class-settings-fields.php
Executable 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
226
includes/class-activator.php
Executable 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
298
includes/class-autoblogger.php
Executable 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
94
includes/class-autoloader.php
Executable 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
67
includes/class-deactivator.php
Executable 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
89
includes/class-encryption.php
Executable 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
175
includes/core/class-chief-editor.php
Executable file
175
includes/core/class-chief-editor.php
Executable 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'] ?? [] ),
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
}
|
||||||
293
includes/core/class-content-analyzer.php
Executable file
293
includes/core/class-content-analyzer.php
Executable 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
297
includes/core/class-content-generator.php
Executable file
297
includes/core/class-content-generator.php
Executable 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
254
includes/core/class-cost-tracker.php
Executable file
254
includes/core/class-cost-tracker.php
Executable 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
202
includes/core/class-ga4-client.php
Executable file
202
includes/core/class-ga4-client.php
Executable 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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
142
includes/core/class-idea-scorer.php
Executable file
142
includes/core/class-idea-scorer.php
Executable 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
220
includes/core/class-logger.php
Executable 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 ) )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
includes/core/class-metrics-collector.php
Executable file
179
includes/core/class-metrics-collector.php
Executable 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() );
|
||||||
|
}
|
||||||
|
}
|
||||||
128
includes/core/class-pipeline-runner.php
Executable file
128
includes/core/class-pipeline-runner.php
Executable 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
269
includes/core/class-publisher.php
Executable 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
167
includes/core/class-source-collector.php
Executable file
167
includes/core/class-source-collector.php
Executable 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
102
includes/models/class-analysis-result.php
Executable file
102
includes/models/class-analysis-result.php
Executable 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
87
includes/models/class-article-idea.php
Executable file
87
includes/models/class-article-idea.php
Executable 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
includes/models/class-content-request.php
Executable file
70
includes/models/class-content-request.php
Executable 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
includes/models/class-content-score.php
Executable file
65
includes/models/class-content-score.php
Executable 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
64
includes/models/class-editorial-review.php
Executable file
64
includes/models/class-editorial-review.php
Executable 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
includes/models/class-generation-log.php
Executable file
81
includes/models/class-generation-log.php
Executable 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
128
includes/models/class-source-data.php
Executable file
128
includes/models/class-source-data.php
Executable 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
39
includes/providers/class-instagram-provider.php
Executable file
39
includes/providers/class-instagram-provider.php
Executable 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' => '' ];
|
||||||
|
}
|
||||||
|
}
|
||||||
137
includes/providers/class-openrouter-pricing.php
Executable file
137
includes/providers/class-openrouter-pricing.php
Executable 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
253
includes/providers/class-openrouter-provider.php
Executable file
253
includes/providers/class-openrouter-provider.php
Executable 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
207
includes/providers/class-reddit-api-client.php
Executable file
207
includes/providers/class-reddit-api-client.php
Executable 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
178
includes/providers/class-reddit-provider.php
Executable file
178
includes/providers/class-reddit-provider.php
Executable 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 : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
40
includes/providers/class-tiktok-provider.php
Executable file
40
includes/providers/class-tiktok-provider.php
Executable 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' => '' ];
|
||||||
|
}
|
||||||
|
}
|
||||||
39
includes/providers/class-youtube-provider.php
Executable file
39
includes/providers/class-youtube-provider.php
Executable 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' => '' ];
|
||||||
|
}
|
||||||
|
}
|
||||||
71
includes/providers/interface-llm-provider.php
Executable file
71
includes/providers/interface-llm-provider.php
Executable 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;
|
||||||
|
}
|
||||||
58
includes/providers/interface-source-provider.php
Executable file
58
includes/providers/interface-source-provider.php
Executable 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
24
phpunit.xml.dist
Executable 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
58
readme.txt
Executable 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
160
scripts/deploy.sh
Executable 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
134
templates/admin/log-viewer.php
Executable 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' => '«',
|
||||||
|
'next_text' => '»',
|
||||||
|
] ) );
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
75
templates/admin/metabox-generation-info.php
Executable file
75
templates/admin/metabox-generation-info.php
Executable 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 ) ); ?>%
|
||||||
|
|
|
||||||
|
<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
115
templates/admin/metrics-page.php
Executable 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
154
templates/admin/review-queue.php
Executable 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' => '«',
|
||||||
|
'next_text' => '»',
|
||||||
|
] ) );
|
||||||
|
echo '</div>';
|
||||||
|
endif;
|
||||||
|
?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
111
templates/admin/settings-page.php
Executable file
111
templates/admin/settings-page.php
Executable 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
65
tests/bootstrap.php
Executable 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
65
tests/unit/ArticleIdeaTest.php
Executable 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
86
tests/unit/EditorialReviewTest.php
Executable file
86
tests/unit/EditorialReviewTest.php
Executable 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
128
tests/unit/GenerationLogTest.php
Executable 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() );
|
||||||
|
}
|
||||||
|
}
|
||||||
147
tests/unit/OpenRouterPricingTest.php
Executable file
147
tests/unit/OpenRouterPricingTest.php
Executable 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
90
uninstall.php
Executable 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user