Compare commits

..

2 Commits

Author SHA1 Message Date
peptiderepo
b071c27780 merge: resolve CHANGELOG conflict after model-picker merge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 16:51:10 +07:00
peptiderepo
84feb5e98b feat(images): wire image pipeline into article flow (commit 1b)
Completes the image + A/B experiment workstream by integrating
image generation into the post publishing pipeline.

New classes:
- class-image-pipeline.php: Orchestrates A/B generation (article-driven
  Image A → featured; source-driven Image B → post meta). Each image
  independently fallible; article publishes even if both fail.
- class-image-prompt-builder.php: Synthesizes visual prompts from article
  title+content and Reddit source data. Appends CEO-locked 90s infomercial
  style suffix. Keeps prompts concise for FLUX.1 quality.
- class-image-media-sideloader.php: Downloads image bytes, creates temp
  file, sideloads via media_handle_sideload(), sets alt text and metadata,
  cleans up temp files.

Changes to existing files:
- Publisher.attach_generated_images(): Runs image pipeline post-creation,
  sets _thumbnail_id (Image A) and _prautoblogger_image_b_id (Image B).
  Errors logged but don't block post publication.
- Settings: prautoblogger_image_enabled (toggle, default off).

Tests:
- ImagePipelineTest.php: Orchestration, cost tracking, graceful failures.
- ImagePromptBuilderTest.php: Prompt generation, length limits.

Docs:
- ARCHITECTURE.md: New step 6b in data flow, new files in file tree.
- CHANGELOG.md: Commit 1a/1b breakdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 16:43:58 +07:00
106 changed files with 3357 additions and 12437 deletions

View File

@@ -6,7 +6,7 @@ on:
jobs:
validate:
name: PHP Lint Gate
name: Validate Code Quality
runs-on: ubuntu-latest
steps:
@@ -17,17 +17,37 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
tools: composer, phpcs
coverage: none
- name: PHP Lint Check
run: |
set -e
find . -name '*.php' \
-not -path './vendor/*' \
-not -path './node_modules/*' \
-not -path './.git/*' \
-not -path './.github/*' \
-print0 | xargs -0 -n1 -P4 php -l > /dev/null
find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*" -exec php -l {} \; | (grep -v "No syntax errors" || true) | head -20
test ${PIPESTATUS[0]} -eq 0
- name: Install Composer dependencies
run: composer install --no-interaction --prefer-dist
- name: Install WordPress Coding Standards
run: |
# Composer 2.2+ blocks plugins by default. The WPCS installer needs
# dealerdirect/phpcodesniffer-composer-installer to wire standards
# into PHPCS. Allow it globally before requiring WPCS.
composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
composer global require "wp-coding-standards/wpcs:^3.0" "phpcsstandards/phpcsutils:^1.0" --no-interaction
phpcs --config-set installed_paths "$(composer global config home)/vendor/wp-coding-standards/wpcs"
- name: Run PHPCS (WordPress standards)
run: |
phpcs --standard=WordPress-Core \
--extensions=php \
--ignore=vendor/*,tests/*,node_modules/* \
--warning-severity=0 \
--report=summary \
includes/ prautoblogger.php uninstall.php || true
- name: Run PHPUnit
run: vendor/bin/phpunit --testdox
deploy:
needs: validate
@@ -65,9 +85,7 @@ jobs:
run: |
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }} \
"cd ~/domains/peptiderepo.com/public_html && \
wp litespeed-purge all 2>/dev/null || \
(find wp-content/litespeed/ -type f -delete 2>/dev/null; touch .lscache_purge 2>/dev/null) || true"
"cd ~/domains/peptiderepo.com/public_html && find wp-content/litespeed/ -type f -delete 2>/dev/null; touch .lscache_purge 2>/dev/null || true"
- name: Health check
run: |

View File

@@ -16,10 +16,9 @@ PRAutoBlogger is a WordPress plugin that monitors social media (starting with Re
│
▼
┌─────────────────────────┐
│ 1. Source Collector │ Pulls data from enabled sources:
│ (Reddit + LLM Research)│ - Reddit RSS/Atom (primary) / .json (fallback)
│ │ - LLM Deep Research (reasoning model)
│ │ Stores raw data in `ab_source_data` table
│ 1. Source Collector │ Pulls raw posts/comments from social platforms
│ (Reddit RSS primary │ Reddit RSS/Atom feeds (primary) / .json (fallback)
│ / .json fallback) Stores raw data in `ab_source_data` table
└────────────┬────────────┘
│
▼
@@ -66,16 +65,10 @@ PRAutoBlogger is a WordPress plugin that monitors social media (starting with Re
│
▼
┌─────────────────────────┐
│ 6b. Image Pipeline │ Generates two A/B images in parallel:
│ 6b. Image Pipeline │ (commit 1b) Generates two A/B images:
│ (optional, if enabled) │ - Image A: article-driven (featured)
│ │ - Image B: source-driven (post meta)
│ │ Provider derived from the saved Image Model
│ │ via Image_Model_Registry::provider_for().
│ │ NSFW-blocked slots (CF error code 3030) are
│ │ retried once with a rule-based fallback
│ │ prompt via Image_NSFW_Retry; a second block
│ │ logs WARNING and publishes without that image.
│ │ Sideloads to media library, logs costs.
│ │ Sideloads to media library, logs costs
└────────────┬────────────┘
│
▼
@@ -111,24 +104,18 @@ prautoblogger/
│ └── posts-widget.js # React component for frontend post cards (wp.element)
│
├── includes/
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution
│ ├── class-executor.php # Cron handlers, generation AJAX (start + status), model registry
│ ├── class-ajax-handlers.php # Non-generation AJAX: images, models, test connections
│ ├── class-generation-lock.php # DB-level atomic mutex for single-writer generation
│ ├── class-prautoblogger.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: sections + core fields (API/Models/Content/Sources)
│ │ ├── class-settings-fields-extended.php # Operational fields: schedule, publishing, display, analytics, images
│ │ ├── class-image-model-registry.php # Static list of image generation models for the model picker
│ │ ├── 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-ideas-browser.php # Browse all analysis results (article ideas)
│ │ ├── class-review-queue.php # Approve/edit/reject queue for generated drafts
│ │ └── class-log-viewer.php # Activity log viewer with level filtering
│ │
@@ -136,55 +123,40 @@ prautoblogger/
│ │ ├── 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-analysis-prompts.php # System/user prompt builders + performance context for analyzer
│ │ ├── class-idea-scorer.php # Ranks and deduplicates article ideas (delegates to Semantic_Dedup)
│ │ ├── class-semantic-dedup.php # Embedding-based cosine similarity dedup with keyword fallback
│ │ ├── class-idea-scorer.php # Ranks and deduplicates article ideas
│ │ ├── class-content-generator.php# Writer agent — manages the generation pipeline
│ │ ├── class-content-prompts.php # Builds all LLM prompts for content generation (system, stage, linking rules)
│ │ ├── class-peptide-linker.php # Deterministic post-processor: hyperlinks peptide mentions to /peptides/ pages
│ │ ├── class-chief-editor.php # Editor agent — LLM-powered editorial review
│ │ ├── class-publisher.php # Creates WordPress posts from approved content
│ │ ├── class-post-assembler.php # Post-creation helpers: taxonomy, log linking, images, sanitization
│ │ ├── class-image-pipeline.php # Orchestrates A/B image generation (parallel via batch)
│ │ ├── class-image-pipeline.php # Orchestrates A/B image generation (commit 1b)
│ │ ├── class-image-prompt-builder.php # Generates visual prompts from article/source data
│ │ ├── class-image-media-sideloader.php # Imports images into WordPress media library
│ │ ├── class-cost-tracker.php # Logs all API costs, enforces budget limits
│ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug)
│ │ ├── class-article-worker.php # Single-article generation (content + edit + publish)
│ │ ├── class-pipeline-runner.php # Orchestrates pipeline; chains per-article cron jobs
│ │ ├── 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-open-router-provider.php # OpenRouter API implementation (chat completions)
│ │ ├── class-open-router-embedding-provider.php # Text embeddings for semantic dedup
│ │ ├── class-open-router-provider.php # OpenRouter API implementation
│ │ ├── class-open-router-pricing.php # Model pricing lookup and cost estimation
│ │ ├── interface-source-provider.php # Contract for any social media source
│ │ ├── class-reddit-json-client.php # Reddit HTTP client — RSS (primary) + .json (fallback)
│ │ ├── class-llm-research-provider.php # LLM deep research source (reasoning models)
│ │ ├── class-reddit-provider.php # Reddit data collection orchestrator (RSS primary)
│ │ ├── interface-image-provider.php # Contract for any image generation provider (incl. batch)
│ │ ├── class-open-router-image-provider.php # OpenRouter image gen (single + batch dispatch)
│ │ ├── class-open-router-image-batch.php # Parallel curl_multi execution for batch image gen
│ │ ├── class-open-router-image-support.php # API key, response parsing, retry/backoff helpers
│ │ ├── class-open-router-image-pricing.php # Model resolution + per-image cost estimation
│ │ ├── class-open-router-config.php # API base URL (direct vs AI Gateway)
│ │ ├── class-open-router-request-builder.php # Header building + Hostinger cURL auth workaround
│ │ ├── class-runware-image-provider.php # Runware FLUX.1 (default v0.9.0+)
│ │ ├── class-runware-image-pricing.php # FLUX schnell/dev cost table + resolver
│ │ ├── class-runware-image-support.php # Key, response parsing, retry, dimension snap
│ │ ├── class-runware-image-batch.php # True parallel curl_multi batch dispatcher
│ │ ├── interface-image-provider.php # Contract for any image generation provider
│ │ ├── class-cloudflare-image-provider.php # FLUX.1 via Cloudflare Workers AI
│ │ ├── class-cloudflare-image-pricing.php # Model alias + per-MP cost estimation
│ │ ├── class-cloudflare-image-validator.php # Non-destructive credential + connectivity check
│ │ ├── class-cloudflare-image-support.php # Token, account, URL, and log helpers for the CF provider
│ │ └── (new providers go here — see CONVENTIONS.md)
│ │
│ ├── services/
│ │ ├── interface-model-registry.php # Contract for provider-specific model registries
│ │ ├── class-open-router-model-registry.php # Fetches, caches, queries the OpenRouter model list
│ │ ├── class-open-router-model-normalizer.php # Maps raw OpenRouter data → standardized record shape
│ │ └── (Phase 3: class-runware-model-registry.php)
│ │ ├── class-openrouter-model-registry.php # Fetches, caches, queries the OpenRouter model list
│ │ ├── class-openrouter-model-normalizer.php # Maps raw OpenRouter data → standardized record shape
│ │ └── (Phase 3: class-cloudflare-workers-ai-model-registry.php)
│ │
│ ├── frontend/
│ │ ├── class-article-typography.php # Inline CSS for font, size, table borders on generated posts
│ │ └── class-posts-widget.php # [prautoblogger_posts] shortcode + REST endpoint
│ │
│ └── models/
@@ -260,53 +232,6 @@ The widget CSS (`posts-widget.css`) uses the Peptide Starter theme's CSS custom
---
## Background Generation Flow (v0.3.0)
The "Generate Now" button runs the pipeline in a background WP-Cron process to avoid
Hostinger's 120-second LiteSpeed connection timeout. The frontend polls for status.
```
┌──────────────────────────────┐
│ Admin clicks "Generate Now" │ admin.js sends AJAX to on_ajax_generate_now()
└────────────┬─────────────────┘
│ Handler returns immediately (< 1 second):
│ 1. Writes "running" transient with started timestamp
│ 2. Schedules one-shot cron: prautoblogger_manual_generation
│ 3. Fires non-blocking loopback to wp-cron.php
│ 4. Returns JSON success to browser
▼
┌──────────────────────────────┐
│ admin.js starts polling │ setInterval every 5 seconds
│ on_ajax_generation_status() │ Shows stage text + spinner
└────────────┬─────────────────┘
│
▼ (separate PHP process)
┌──────────────────────────────┐
│ on_manual_generation() │ WP-Cron fires this in a new request
│ (class-executor.php) │ 1. ignore_user_abort(true) — survives HTTP kill
│ │ 2. set_time_limit(300)
│ │ 3. Acquires PRAutoBlogger_Generation_Lock
│ │ 4. Runs PRAutoBlogger_Pipeline_Runner::run()
│ │ (pipeline broadcasts stage updates to transient)
│ │ 5. Writes final result to transient
│ │ 6. Releases lock
└────────────┬─────────────────┘
│
▼
┌──────────────────────────────┐
│ Poller detects completion │ Reads transient: status = 'complete' or 'error'
│ Shows result message │ Resets button to idle state
└──────────────────────────────┘
```
**Fallback detection:** If Hostinger kills the PHP process before it updates the
transient (despite `ignore_user_abort`), the status endpoint detects the orphan after
180 seconds by checking for recently-created posts with `_prautoblogger_run_id` meta.
It also releases any stale generation lock. After 600 seconds (STATUS_TTL), it gives
up unconditionally and reports a timeout.
---
## Database Schema
### Custom Tables
@@ -435,14 +360,9 @@ All prefixed with `prautoblogger_`:
| `prautoblogger_log_level` | Logging threshold: error/warning/info/debug |
| `prautoblogger_db_version` | Schema version for migrations |
| `prautoblogger_schedule_time` | Daily generation time (HH:MM, default: '03:00') |
| `prautoblogger_image_model` | Image model slug from `Image_Model_Registry::get_models()`; provider is derived from this on save |
| `prautoblogger_image_provider` | Derived from the chosen model on save (v0.8.0+); not editable in admin UI |
| `prautoblogger_image_prompt_instructions` | System prompt given to the image rewriter LLM (v0.8.0+); falls back to `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` when empty |
| `prautoblogger_image_nsfw_retry` | Toggle: retry NSFW-blocked image slots with a rule-based fallback prompt (default: '1') |
| `prautoblogger_migrated_image_provider_v080` | One-shot migration flag — auto-heals mismatched provider/model pairs on first admin_init after v0.8.0 upgrade |
| `prautoblogger_article_font_family` | Font family key: 'default', 'inter', 'georgia', 'merriweather', 'lora', 'open_sans', 'roboto', 'system' |
| `prautoblogger_article_font_size` | Body font size in px (0 = theme default). Recommended: 16–18. |
| `prautoblogger_table_borders` | Toggle: add borders/padding/striping to tables (default: '1') |
| `prautoblogger_cloudflare_ai_token` | Encrypted Cloudflare Workers AI API token |
| `prautoblogger_cloudflare_account_id` | Cloudflare account UUID (plaintext — identifier, not secret) |
| `prautoblogger_image_model` | Image model alias: `flux-1-schnell` (default) or `flux-1-dev` |
| `prautoblogger_image_style_suffix` | Text appended to every image prompt (default: CEO-locked 90s infomercial prompt) |
| `prautoblogger_openrouter_model_registry` | Normalized OpenRouter model list (JSON array, daily refresh, serves as durable cache) |
| `prautoblogger_openrouter_model_registry_fetched_at` | Unix timestamp of last successful model registry refresh |
@@ -476,7 +396,7 @@ Stored on every PRAutoBlogger-generated post:
| Reddit RSS | Primary Reddit data source — Atom feeds for subreddit hot posts | None (unauthenticated) | No known rate limit; reliable from datacenter IPs | `providers/class-reddit-json-client.php` |
| Reddit .json | Fallback for posts + only source for comments | None (unauthenticated) | ~10 req/min (datacenter IPs often blocked) | `providers/class-reddit-json-client.php` |
| Google Analytics 4 | Post performance metrics | OAuth2 service account | Standard GA4 limits | `core/class-ga4-client.php`, `core/class-metrics-collector.php` |
| Runware (FLUX.1 via runware.ai) | **Default** image backend (v0.9.0+): schnell ~$0.0006/image, dev ~$0.02/image. True parallel generation via curl_multi. | API key (encrypted in wp_options) | Account-level quotas | `providers/class-runware-image-provider.php`, `providers/class-runware-image-pricing.php`, `providers/class-runware-image-support.php`, `providers/class-runware-image-batch.php` |
| Cloudflare Workers AI | Image generation (FLUX.1 schnell / dev) for article hero, thumbnail, and IG placements | API token (encrypted in wp_options) + account ID | Workers AI per-account quotas | `providers/class-cloudflare-image-provider.php`, `providers/class-cloudflare-image-pricing.php`, `providers/class-cloudflare-image-validator.php` |
---
@@ -519,25 +439,17 @@ The posts widget uses WordPress-bundled React (`wp.element`) with raw `createEle
The widget fetches posts asynchronously from a dedicated REST endpoint rather than rendering server-side. This keeps initial page load fast and allows the endpoint to be cached independently (5-minute Cache-Control). The permission callback uses a filter (`prautoblogger_rest_posts_public`) so sites can restrict access if needed.
### #13: Run-ID based log linking
Each pipeline execution generates a UUID (`run_id`) that tags every `prab_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. **v0.8.1+**: `run_id` is also persisted on each post as `_prautoblogger_run_id` post_meta, giving the orphan-research reaper a direct path from run to siblings without walking the gen_log table (see #19).
### #19: Daily orphan-research-row reaper (v0.8.1)
When a pipeline dies between post creation and the final `Post_Assembler::amortize_research_costs()` call (Hostinger exec kill, OOM, LiteSpeed cut, fatal), the shared `llm_research` cost row stays in `prab_generation_log` with `post_id = NULL` and sibling articles understate their true cost in the breakdown popover. A new daily WP-Cron event `prautoblogger_reap_orphan_research_rows` (hooked to `PRAutoBlogger_Research_Reaper::on_cron`, 03:15 server time — 15 min after the primary generation cron) scans for orphan rows older than a 1-hour grace window, looks up sibling posts by `_prautoblogger_run_id` post_meta (fallback: distinct `post_id`s in gen_log for the same run_id), and delegates to the existing idempotent `amortize_research_costs()` on match. Orphans that still have no siblings after 7 days are deleted outright as sunk cost. A one-time activator migration (`prautoblogger_migrated_run_id_meta_v081`) back-populates `_prautoblogger_run_id` on existing posts from gen_log so the meta-primary path works on upgrade. Manual ops trigger: `wp prautoblogger reap-research`.
Each pipeline execution generates a UUID (`run_id`) that tags every `prab_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.
### #14: Reddit RSS replaces PullPush.io (and earlier Reddit OAuth)
Reddit rejected our OAuth API application (April 2026). We initially switched to PullPush.io, but its index was frequently stale or unavailable. Reddit's RSS/Atom feeds (`/r/{sub}/hot.rss`) proved the most reliable option — they work from datacenter IPs where .json gets 403, require no auth, and have no apparent rate limit. The .json endpoints are kept as a fallback for posts and as the sole source for comment data. Each collected item's metadata includes a `data_source` field (`reddit_rss` or `reddit_json`) for auditability.
### #18: OpenRouter model registry — daily refresh, WP option + transient cache, zero-coupling
The admin model picker (v1) needs to list OpenRouter models with pricing and capabilities. We fetch `https://openrouter.ai/api/v1/models` (free, unauthenticated) once daily and store the normalized payload in a WP option fronted by a 24h transient. On stale-and-fetch-fails, we serve last-good + surface a warning. The registry class (`class-open-router-model-registry.php`) takes all config via constructor (option name, transient name, endpoint URL) — no PRAUTOBLOGGER_* constants inside the class body. Phase 2 lifts it into a shared Composer package with zero internal edits. Phase 3 adds a parallel `Cloudflare_WorkersAI_Model_Registry` behind the same interface. Capability vocabulary: `text→text`, `text+image→text`, `text+audio→text`, `text→image`, `text→audio`, `text→video`, `text→embedding`. Cost: $0/month.
### #17: Runware as default image backend (v0.9.0, Apr 2026)
After a comic-style A/B round in Apr 2026, FLUX.1 schnell via Runware was chosen as the default image backend. Cost is ~$0.0006/image vs ~$0.039/image for Gemini 2.5 Flash Image via OpenRouter (≈65× cheaper), and the looser schnell fidelity reads as editorial-cartoon style — a feature, not a bug, for our single-panel comic aesthetic. FLUX.1 dev (~$0.02/image, 28 steps) remains available as an opt-in for higher-fidelity runs. The Runware layer mirrors the OpenRouter split (interface + provider + support + pricing + batch), all under the 300-line cap. The batch class dispatches `imageInference` POSTs via `curl_multi_init/exec/select` — wall-clock time for the A/B pair drops to ≈ the slowest single image. A v0.9.0 one-time migration (`prautoblogger_migrated_default_image_v090`) flips sites still on the legacy default; explicit user selections are preserved. Cloudflare Workers AI was removed as an image provider in v0.10.0 (see ADR #16 note below).
### #16: Image generation via Cloudflare Workers AI (FLUX.1) — SUPERSEDED v0.10.0
Kept here for history. Until v0.8.2 the default image backend was FLUX.1 schnell on Cloudflare Workers AI, routed via the Cloudflare AI Gateway for caching + unified telemetry. In v0.9.0 (2026-04-21) Runware FLUX.1 schnell became the default (65× cheaper, see ADR #17). In v0.10.0 (2026-04-21) the Cloudflare Workers AI provider was removed entirely — classes, settings, tests, and registry entries are gone; any CF user is migrated to `runware:100@1` on upgrade via `PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run()`. The Cloudflare AI Gateway in front of OpenRouter (ADR #15) is unaffected — it is a distinct integration.
The admin model picker (v1) needs to list OpenRouter models with pricing and capabilities. We fetch `https://openrouter.ai/api/v1/models` (free, unauthenticated) once daily and store the normalized payload in a WP option fronted by a 24h transient. On stale-and-fetch-fails, we serve last-good + surface a warning. The registry class (`class-openrouter-model-registry.php`) takes all config via constructor (option name, transient name, endpoint URL) — no PRAUTOBLOGGER_* constants inside the class body. Phase 2 lifts it into a shared Composer package with zero internal edits. Phase 3 adds a parallel `Cloudflare_WorkersAI_Model_Registry` behind the same interface. Capability vocabulary: `text→text`, `text+image→text`, `text+audio→text`, `text→image`, `text→audio`, `text→video`, `text→embedding`. Cost: $0/month.
### #16: Image generation via Cloudflare Workers AI (FLUX.1), direct (not via AI Gateway)
The image-generation layer (started 2026-04-15, tracked in `convo/prautoblogger/threads/2026-04-image-pipeline/`) uses FLUX.1 [schnell] on Cloudflare Workers AI as its default model, chosen for its ~$0.0011/MP cost and 2–3 sec latency. FLUX.1 [dev] is a ~4× cost upgrade exposed as a dropdown for specific posts that warrant it. Calls go directly to `https://api.cloudflare.com/client/v4/accounts/{id}/ai/run/@cf/black-forest-labs/...`, *not* through the Cloudflare AI Gateway that we use for OpenRouter — the gateway route to Workers AI currently 403s (pre-existing open issue). The provider lives behind `PRAutoBlogger_Image_Provider_Interface`, so the decision is trivially reversible: switching to a different image API (DALL-E, Replicate, a future AI Gateway route) is a new class implementation plus a one-line swap in the pipeline wiring. Trade-off: we run two different Cloudflare integration paths (gateway for OpenRouter LLMs, direct for Workers AI images) until the gateway route stabilizes — minor cognitive overhead, zero functional downside.
### #15: Optional Cloudflare AI Gateway in front of OpenRouter
We already use Cloudflare for DNS/CDN on peptiderepo.com, so layering AI Gateway in front of OpenRouter is zero marginal infrastructure. It gives us response caching (meaningful for repeated classification/scoring calls), a unified cost/latency dashboard, rate limiting, and provider fallback — all of which we would otherwise have to build ourselves to satisfy the CTO cost-tracking rules. Kept as an opt-in URL setting (`prautoblogger_ai_gateway_base_url`) so the plugin still works unchanged out of the box and can be bypassed instantly if the gateway misbehaves. The gateway is a transparent OpenRouter-compatible proxy; no new provider class is needed, and the response parsing path (`usage`, `choices[0].message.content`) is unchanged.

View File

@@ -5,506 +5,6 @@ All notable changes to PRAutoBlogger 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.10.0] — 2026-04-21
### Removed
- **Cloudflare Workers AI as image provider.** The four `class-cloudflare-image-*`
provider files are gone along with their unit tests and the three
CF-specific options (`prautoblogger_cloudflare_ai_token`,
`prautoblogger_cloudflare_account_id`, `prautoblogger_cf_image_via_gateway`).
Runware FLUX.1 schnell produces equivalent-quality output at ~65×
less cost and eliminates a duplicated settings surface. The
`flux-1-schnell` registry entry that mapped to the CF provider is
removed; the legacy slug now resolves to an empty provider (caught
by the migration).
### Changed
- **Sites on any `cloudflare` image provider auto-migrate to
`runware:100@1` on upgrade.** New migration
`PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run()` runs from
`admin_init` once per site. Detects three legacy shapes: registry-
derived provider `cloudflare`, `prautoblogger_image_provider` option
set to `cloudflare`, or the legacy slug `flux-1-schnell`. Sets
`prautoblogger_image_provider` + `prautoblogger_image_model` to the
Runware schnell pair and deletes the three dead CF options. Idempotent
via `prautoblogger_migrated_remove_cloudflare_v0100` flag — running
twice is a no-op and will not clobber a post-migration manual choice.
- **Image provider dispatch fallback is now Runware, not Cloudflare.**
`PRAutoBlogger_Image_Pipeline::create_default_provider()` returns
`PRAutoBlogger_Runware_Image_Provider` for any unrecognised
`prautoblogger_image_provider` value. An unmigrated install still
gets a working pipeline instead of a fatal.
### Note
- **Cloudflare AI Gateway fronting OpenRouter is unaffected.** The
`prautoblogger_ai_gateway_base_url` + `prautoblogger_ai_gateway_cache_ttl`
options, their settings fields, `class-open-router-config.php`
gateway resolution, and `class-open-router-request-builder.php`
`cf-aig-cache-ttl` headers are all intentionally untouched. The AI
Gateway is a proxy/cache layer in front of OpenRouter and is
orthogonal to the CF Workers AI image provider removal. The
migration test asserts this invariant explicitly.
### Cost impact
Zero recurring spend from CF Workers AI image generation (the
peptiderepo.com install was still on CF as recently as v0.8.2 — see
commit `80a7e11`). No new prompt tokens, no change in model mix for
sites already on Runware or OpenRouter.
### Migration checklist (for anyone else running this plugin)
1. On first admin_init after upgrade, the migration will flip
`prautoblogger_image_provider` to `runware` and
`prautoblogger_image_model` to `runware:100@1` if you were on CF.
2. **Set `prautoblogger_runware_api_key`** in Settings → Images if you
hadn't already — image generation will fail until it is set.
3. The CF API token + account ID options are deleted by the migration;
you don't need to do anything.
4. If you want to switch back to OpenRouter (Gemini) instead of
Runware, just change the Image Model picker after the migration
has run.
## [0.9.0] — 2026-04-21
### Added
- **Runware (FLUX.1 schnell) as the default image provider.** New
provider at `includes/providers/class-runware-image-*` generates
~$0.0006/image on FLUX.1 schnell (`runware:100@1`) — roughly 65× cheaper
than the previous default (Gemini 2.5 Flash Image via OpenRouter at
~$0.039/image). FLUX.1 dev (`runware:101@1`, ~$0.02/image) is also
available for higher-fidelity runs. The provider mirrors the
OpenRouter split: interface + provider + support + pricing + batch,
all under the 300-line cap.
- **True parallel image generation.** `PRAutoBlogger_Runware_Image_Batch`
uses `curl_multi_init/exec/select` to fire all imageInference POSTs
concurrently, then downloads the signed URLs concurrently. Wall-clock
for the A/B pair drops to ≈ the slowest single image instead of the
sum of both.
- **v0.9.0 one-time migration.** Sites where the image model is empty
or still pinned to `google/gemini-2.5-flash-image` are auto-flipped
to `runware:100@1` on upgrade, with the provider re-derived from the
registry. Flag: `prautoblogger_migrated_default_image_v090`. The
Runware API key option (`prautoblogger_runware_api_key`) was added
to the `enc:`-prefix migration list so Settings writes go through
the same encryption-at-rest path as the other provider keys.
### Changed
- **Image prompt builder steered away from abstract/chemical subjects.**
The rewriter system prompt now explicitly refuses to personify
molecules, peptides, hormones, or proteins — FLUX (and most diffusion
image models) render "anthropomorphic peptide" as an incoherent blob.
Default scene guidance is now "concrete, human-scale scenes: people
reacting to things, doctors and patients, gym bros with vials, etc."
Style suffix unchanged — single-panel comic aesthetic preserved. See
`includes/core/class-image-prompt-builder.php::REWRITER_SYSTEM_PROMPT`.
- **Default image model flipped to `runware:100@1` (FLUX.1 schnell).**
`PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER` and
`PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL` constants in `prautoblogger.php`
updated. Cloudflare flux-1-schnell remains in the registry as a legacy
fallback for any operators who set it explicitly. Rationale:
`image-style` memory (2026-04-21) — schnell passed the comic A/B
round and the cost delta funds more generations for the same monthly
budget.
### Cost impact
At 50 articles/day × 2 image A/B = 100 images/day:
- Before (Gemini 2.5 Flash Image via OpenRouter): ≈ $3.90/day, ≈ $117/mo
- After (FLUX.1 schnell via Runware): ≈ $0.06/day, ≈ $1.80/mo
Net saving: ~$115/mo at current traffic, funded by a single provider
swap with no user-visible change in article quality.
## [0.8.2] — 2026-04-21
### Fixed
- **Daily generation schedule now honors site timezone.** Previously
the time configured in Settings → Schedule was interpreted as UTC
(WordPress core forces `date_default_timezone_set('UTC')`), causing
runs to fire N hours off from user intent where N is the site's UTC
offset. On peptiderepo.com (Asia/Singapore, UTC+8), a `schedule_time`
of `06:00` fired at 14:00 local. An on-upgrade one-shot migration
(`prautoblogger_migrated_schedule_tz_v082`) reschedules the existing
daily cron in the site's configured timezone — no user action
required.
### Changed
- **Cloudflare Workers AI image calls now route through the AI Gateway
by default.** The 2026-04-15 regression that caused the gateway route
to 403 on Workers AI has been resolved upstream. Routing via the
gateway gives us response caching, unified cost/latency dashboard,
and rate limiting for free, mirroring the OpenRouter LLM path. A new
admin toggle `prautoblogger_cf_image_via_gateway` (default on) lets
operators force direct-API if the gateway route regresses again,
without clearing the gateway URL setting the LLM provider depends on.
- **"Generation Time" settings description** now names the site's
configured timezone explicitly (from `wp_timezone_string()`) instead
of the ambiguous "server timezone" label — operators can see at a
glance which zone their input is being interpreted in.
## [0.8.1] — 2026-04-21
### Fixed
- **Orphaned LLM research costs now reaped daily.** When a pipeline died
between post creation and the final cost-amortization step (e.g.
execution-time kill on shared hosting), the `llm_research` row stayed
unlinked and the affected articles' cost popovers understated true spend.
A new daily cron (`prautoblogger_reap_orphan_research_rows`, 03:15
server time) retroactively amortizes orphan rows against posts from
the same run. Includes a one-time activator migration to back-populate
`_prautoblogger_run_id` post_meta for existing posts.
- **GA4 Property ID admin description corrected.** The field description
previously instructed users to enter `properties/XXXXXXXXX`, but the
GA4 client already prepends the `properties/` URL segment internally —
a double-prefixed value would 404. Description now asks for the digits
only, matching actual code behavior.
### Added
- New WP-CLI command `wp prautoblogger reap-research` for manual reaping
and ops debugging.
- `Publisher::build_meta()` now writes `_prautoblogger_run_id` on every
generated post, so the reaper has a direct meta path from run_id to
sibling posts without walking `wp_prautoblogger_generation_log`.
## [0.8.0] — 2026-04-21
### Changed
- **Single Image Model dropdown in admin → Images.** The separate Image
Provider select has been removed. The provider (OpenRouter or
Cloudflare Workers AI) is now derived from the chosen model's entry
in `PRAutoBlogger_Image_Model_Registry` on save, so mismatched
provider/model pairs — the root cause of posts 650 and 657 silently
missing their featured image on 2026-04-20 — are no longer possible.
A one-shot migration runs on first admin_init after upgrade and
auto-heals any existing site where the saved provider and model
disagreed. The `prautoblogger_image_provider` option key is
preserved; all existing reads in the pipeline continue to work.
### Added
- **Image Prompt Instructions setting.** The system prompt used to
rewrite articles into SCENE + CAPTION is now editable from admin →
Images. Default preserves prior behavior; edit to reshape the
creative direction without a code change.
- **Retry NSFW-Blocked Images setting** (default on). When the image
provider rejects a prompt as NSFW — Cloudflare Workers AI returns
HTTP 400 with `errors[].code === 3030` on FLUX.1 schnell —
PRAutoBlogger now retries the slot once with a rule-based fallback
prompt (article title + style suffix, no LLM rewrite). On a second
block it logs a WARNING and publishes without that image, matching
existing behaviour for any unrecoverable image failure.
### Fixed
- **Silent per-image failures in the event log.** The OpenRouter batch
provider now emits `Logger::error` with the request key for every
per-slot HTTP/cURL/parse failure (previously logged the error but
not which slot it was), and `Image_Pipeline::process_image_a/_b`
emit `Logger::warning` on `['error' => …]` results whether or not
the outer catch fires. Operators can now see NSFW blocks, 4xx
bodies, and timeouts in the admin Event Log without cross-
referencing PHP error_log timestamps.
## [0.7.3] — 2026-04-20
### Fixed
- **Table colors broken in dark mode.** Replaced hardcoded hex colors
with theme CSS custom properties (`--color-border-default`,
`--color-bg-secondary`, `--color-text-primary`) so table borders,
header backgrounds, and text adapt to light/dark mode automatically.
## [0.7.2] — 2026-04-20
### Fixed
- **Bullet points missing in articles.** Theme's global CSS reset strips
`list-style` from all `ul`/`ol`. Typography CSS now restores disc/decimal
bullets with proper padding inside `.entry-content`.
- **Tables break on mobile.** Tables now wrapped in a horizontally
scrollable container (`prab-table-wrap`) via `the_content` filter.
Added responsive media query for tighter padding and smaller font at
narrow viewports.
## [0.7.1] — 2026-04-20
### Fixed
- **Typography CSS not applying.** Two bugs: CSS targeted `.post-content`
but the theme template uses `.entry-content`, and the
`_prautoblogger_generated` meta gate excluded posts that predate the
meta flag. Display settings now apply to all single post pages and
target the correct `.entry-content` selector.
## [0.7.0] — 2026-04-20
### Added
- **Display settings section.** New "Display" tab in admin with three
settings that control how generated articles render on the frontend:
- **Article Font** — choose from 7 font families (Inter, Georgia,
Merriweather, Lora, Open Sans, Roboto, System) or keep theme default.
Google Fonts are loaded only on PRAutoBlogger posts that need them.
- **Article Font Size** — set body text size in pixels (0 = theme
default). Recommended 16–18px for comfortable reading.
- **Table Borders** — adds visible borders, cell padding, header
background, and alternating row colors to tables. Enabled by default.
- **Article_Typography frontend class.** Injects inline CSS only on
singular post pages with `_prautoblogger_generated` meta — zero
impact on non-PRAutoBlogger pages.
## [0.6.3] — 2026-04-20
### Fixed
- **Unchecking all sources still used Reddit.** Browser omits checkbox fields
from POST when none are checked, so the `prautoblogger_enabled_sources`
option never updated. Added a hidden input so an empty value is always
submitted — unchecking all sources now correctly saves `[]`.
- **Pipeline status hardcoded "Reddit".** The "Collecting sources…" stage
message now reflects the actually-enabled sources instead of always
saying "Reddit".
## [0.6.2] — 2026-04-20
### Added
- **Deterministic peptide auto-linker.** New `Peptide_Linker` class scans
generated HTML and wraps every mention of a known peptide in a hyperlink
to its `/peptides/{slug}/` database page. Runs as a post-processing step
in Publisher before `wp_insert_post` — no LLM involvement, 100% reliable.
Handles case-insensitive matching, hyphen/space variants (e.g., "BPC-157"
and "BPC 157"), and skips text already inside `<a>` tags. Gracefully
no-ops if PR Core is not active.
### Changed
- **Removed prompt-based peptide linking.** The system prompt no longer asks
the LLM to link peptides (unreliable). Peptide links are now injected
deterministically by `Peptide_Linker` after content generation. The prompt
still provides article links and the "never fabricate URLs" rule.
## [0.6.1] — 2026-04-20
### Added
- **Peptide auto-linking.** When any peptide name from the PR Core database is
mentioned in a generated article, the first mention is automatically linked to
the corresponding `/peptides/{slug}/` page. The system prompt now includes a
complete peptide database reference list alongside the existing article links.
Gracefully degrades if PR Core is not active (`post_type_exists()` guard).
### Changed
- **Extracted prompt builders into Content_Prompts class.** Moved all prompt
construction (system prompt, stage prompts, linking rules) from
`Content_Generator` into a new `Content_Prompts` static helper. Generator
dropped from 324 → 162 lines; prompts class is 252 lines. Both under 300.
- **Internal link rules strengthened.** Linking rules section now explicitly
instructs the model to link every peptide's first mention and never fabricate
URLs, with real published article and peptide page URLs provided.
## [0.6.0] — 2026-04-20
### Added
- **Generate article from idea.** New "Generate" column in the Ideas browser.
Click any idea's Generate button to produce an article directly, bypassing
the scheduled pipeline's collect → analyze → score steps. The button shows
real-time stage updates (drafting, editing, publishing) via per-idea status
polling, then swaps to Edit/View links with cost on completion.
- `Ideas_Browser::on_ajax_generate_from_idea()` — AJAX trigger that stores
the idea and schedules a one-shot WP-Cron event.
- `Ideas_Browser::on_cron_generate_from_idea()` — Background handler that
runs Article_Worker for the selected idea.
- `Ideas_Browser::on_ajax_idea_gen_status()` — Per-idea status polling.
- `assets/js/ideas-browser.js` — Button click, AJAX, polling, and UI state.
- Per-idea transients (`prab_idea_gen_{id}`) for independent status tracking.
- Page-load polling resume for ideas that were mid-generation.
- Generation lock respected — only one generation at a time.
## [0.5.3] — 2026-04-20
### Fixed
- **Writing instructions not obeyed by LLM.** The user-configured writing
instructions (bullet points, hyperlinks, style rules) were appended to the
system prompt as a weak "Additional instructions:" afterthought. Models
prioritized competing format directives in the user prompts and ignored
the style guide.
- System prompt now frames writing instructions as a "MANDATORY STYLE GUIDE"
with explicit override language.
- Every stage's user prompt (outline, draft, single-pass) now includes a
reinforcement line: "Follow EVERY requirement from your system prompt
style guide."
- Polish stage explicitly preserves bullet points, numbered lists, and
hyperlinks instead of flattening them.
- Chief editor's revision rules now preserve structural formatting (lists,
links) from the original draft.
## [0.5.2] — 2026-04-20
### Added
- **LLM research cost amortization.** The research LLM call (which runs once
per pipeline execution) is now divided evenly across all articles produced
in that run. Each article's cost breakdown popover shows its amortized share
of the research overhead. Previously, research costs were orphaned with no
post attribution.
- `Post_Assembler::amortize_research_costs()` — Queries the unlinked
research log entry, divides cost and tokens by article count, inserts one
row per article, and removes the original.
- `link_generation_logs()` now excludes `llm_research` stage from per-article
linking so the cost isn't grabbed wholesale by the first article.
- `Source_Collector::set_cost_tracker()` — Accepts the pipeline's cost
tracker so research costs are tagged with the pipeline's `run_id`.
- `LLM_Research_Provider` now uses the pipeline's cost tracker when available,
ensuring research log entries share the same `run_id` as article entries.
- Amortization runs after all articles complete (both single-article and
chained-job paths in `Pipeline_Runner`).
## [0.5.1] — 2026-04-20
### Added
- **Ideas browser admin page.** New "Ideas" submenu under PRAutoBlogger showing
all article ideas collected by the content analyzer. Displays suggested title,
topic, type (question/complaint/comparison/news/guide), relevance score bar,
frequency, key points, and target keywords. Filterable by type with count
badges. Paginated at 30 per page, newest first.
## [0.5.0] — 2026-04-20
### Added
- **LLM Deep Research source provider.** New data source that sends a configurable
research prompt to a reasoning-capable model (e.g. Grok 4.1 Fast, DeepSeek-R1)
to identify trending topics, emerging questions, misconceptions, and content gaps.
Findings feed into the content analyzer alongside Reddit data, giving the pipeline
both real-time community signals AND deep knowledge-base research.
- `includes/providers/class-llm-research-provider.php` — Source provider implementation.
- New admin settings in Sources tab:
- "LLM Deep Research" checkbox in Enabled Sources.
- "Research Model" model picker — pick a reasoning-capable model.
- "Research Prompt" textarea — customizable research brief with `{niche}` placeholder.
- Each research run produces 8-12 findings stored as `llm_research` source data.
- Deduplication via date-based source IDs (one fresh run per day, refreshes on re-run).
- Cost tracked as `llm_research` stage in generation logs.
- Content analyzer now formats LLM research findings with a cleaner label
(`[LLM Research] Relevance: N`) instead of the Reddit-specific `r/subreddit` format.
## [0.4.9] — 2026-04-19
### Added
- **OpenRouter reasoning mode support.** New "Enable Reasoning" toggle and
"Reasoning Effort" selector in AI Models settings. When enabled, sends
`reasoning: {enabled: true, effort: "<level>"}` to models that support it
(Grok 4.1 Fast, DeepSeek-R1, etc.). Effort levels: Extra High, High,
Medium, Low, Minimal. Reasoning tokens are billed as output tokens — cost
is automatically tracked. Models that don't support reasoning ignore the
parameter. Per-call override available via the `reasoning` key in the
provider options array.
- Response parser now captures `reasoning_tokens` and `reasoning_content`
from the OpenRouter response for downstream logging and debugging.
## [0.4.8] — 2026-04-19
### Fixed
- **GLM 4.7 Flash timeout during analysis.** The model was timing out at 120s on
large analysis prompts (especially with the new custom instructions). Bumped
API timeout from 120s to 180s.
- **Empty raw response in error logs.** Parse failure errors now include the raw
LLM response inline (first 500 chars) at error level, not debug level, so
the actual failure reason is always visible in the Activity Log.
## [0.4.7] — 2026-04-19
### Added
- **Cost breakdown popover.** The Cost column in the post list is now clickable.
Hovering or clicking shows a per-step breakdown: stage name, model used, token
count, and cost for every LLM call that produced the article.
## [0.4.6] — 2026-04-19
### Added
- **Image B toggle.** New "Generate Second Image (B)" toggle in Images settings.
Disabling saves one image generation call + one LLM prompt rewrite per article.
Defaults to enabled for backwards compatibility.
## [0.4.5] — 2026-04-19
### Added
- **Analysis Instructions setting.** Custom instructions appended to the analysis
LLM's system prompt. Steers how source data is evaluated and which topic ideas
get surfaced.
- **Editor Instructions setting.** Custom instructions appended to the chief
editor's system prompt. Controls what the editorial review looks for and how
it decides to approve, revise, or reject articles.
## [0.4.4] — 2026-04-19
### Added
- **Writing Instructions setting.** New textarea in Content settings lets you
provide custom instructions appended to the LLM's system prompt when writing
articles. Use it to steer style, structure, voice, and formatting without
editing code.
## [0.4.3] — 2026-04-19
### Fixed
- **"Analysis response was not valid JSON" with some models.** Models like
GPT 5.1 Nano ignore `response_format: json_object` and wrap JSON in markdown
fences or preamble text. New `PRAutoBlogger_Json_Extractor` utility strips
fences and extracts the outermost JSON object. Applied to both the content
analyzer and chief editor parsers. Raw response now logged on failure for
debugging.
## [0.4.2] — 2026-04-19
### Fixed
- **Posts list Title column unreadable.** Title was compressed to ~30px by the
three custom columns. Title now gets 35% minimum width with word-wrap enabled.
Model columns trimmed to 100px with text-overflow ellipsis for long model names.
## [0.4.1] — 2026-04-19
### Added
- **Posts list columns: Writing Model, Image Model, Cost.** Three new columns
on the WordPress Posts admin page. Writing Model shows the LLM that wrote the
article, Image Model shows the model that generated the featured image, and
Cost shows the total API spend for generating that article (summed from the
generation log). All columns show "—" for non-generated posts. Writing Model
is sortable. Model names are shortened (provider prefix stripped) with full
ID in a tooltip.
## [0.4.0] — 2026-04-19
### Added
- **Semantic dedup via embedding cosine similarity.** Replaces keyword-overlap
dedup (60% word match) with MiniLM-L12-v2 embeddings via OpenRouter. Catches
rephrasings like "BPC-157 dosing" vs "how much BPC-157 to take" that keyword
matching misses. Automatic fallback to keywords if embedding API unavailable.
Cost: ~$0.00001 per generation run.
- `includes/core/class-semantic-dedup.php` — Dedup engine with embedding +
keyword fallback.
- `includes/providers/class-open-router-embedding-provider.php` — OpenRouter
`/embeddings` client with batch support and cosine similarity helper.
- **LLM-aware topic avoidance.** Analysis prompt now includes the last 30 days
of published article titles with explicit "do not suggest similar topics"
instruction. Zero additional cost — appended to the existing analysis call.
### Changed
- Dedup window widened from 7 days to 30 days (semantic similarity is precise
enough to avoid over-blocking).
- `class-idea-scorer.php` refactored to delegate dedup to `Semantic_Dedup`.
### Fixed
- Featured images displayed at 300×300 (WordPress "medium" size) instead of full
width. Changed theme to use `the_post_thumbnail('full')` with responsive CSS.
- Race condition causing duplicate article generation when the AJAX status poller
re-scheduled a cron event for an article already being generated.
- False stall detection on multi-article runs (measured elapsed time from start
instead of idle time since last progress update).
## [Unreleased]
### Added
@@ -540,8 +40,8 @@ swap with no user-visible change in article quality.
list (`/api/v1/models` — free, unauthenticated). Daily refresh via the
existing `prautoblogger_daily_generation` cron hook with 12h idempotency.
- `includes/services/interface-model-registry.php` — Phase 3-aware contract.
- `includes/services/class-open-router-model-registry.php` — fetch + cache + query.
- `includes/services/class-open-router-model-normalizer.php` — raw → standardized shape.
- `includes/services/class-openrouter-model-registry.php` — fetch + cache + query.
- `includes/services/class-openrouter-model-normalizer.php` — raw → standardized shape.
- Capability vocabulary: `text→text`, `text+image→text`, `text→embedding`, etc.
- Zero-coupling: no PRAUTOBLOGGER_* constants inside the class — Phase 2 lift
into a shared Composer package requires only a namespace rename.

View File

@@ -1,206 +0,0 @@
/**
* Model picker popup styles for PRAutoBlogger admin.
*
* Renders a modal overlay with a searchable list of AI models.
* Uses the same design tokens as admin.css (--ab-* CSS variables).
*
* @see assets/js/model-picker.js — JS that builds and manages the popup DOM.
* @see assets/css/admin.css — Parent design system variables.
*/
/* ── Overlay ────────────────────────────────────────────────────────── */
.ab-mp-overlay {
position: fixed; inset: 0; z-index: 100100;
background: rgba(0, 0, 0, 0.45);
display: flex; align-items: center; justify-content: center;
animation: ab-mp-fade-in 0.15s ease;
}
@keyframes ab-mp-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* ── Popup container ────────────────────────────────────────────────── */
.ab-mp-popup {
background: var(--ab-card-bg, #fff);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
width: 640px; max-width: 90vw;
max-height: 80vh;
display: flex; flex-direction: column;
animation: ab-mp-slide-up 0.2s ease;
}
@keyframes ab-mp-slide-up {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Header ─────────────────────────────────────────────────────────── */
.ab-mp-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid var(--ab-border, #e2e8f0);
}
.ab-mp-title {
font-size: 16px; font-weight: 700;
color: var(--ab-text, #1e293b);
}
.ab-mp-close {
background: none; border: none; cursor: pointer;
font-size: 22px; line-height: 1; color: var(--ab-text-muted, #64748b);
padding: 4px 8px; border-radius: 4px;
}
.ab-mp-close:hover {
background: var(--ab-bg, #f8fafc);
color: var(--ab-text, #1e293b);
}
/* ── Search ─────────────────────────────────────────────────────────── */
.ab-mp-search-wrap {
padding: 12px 20px;
border-bottom: 1px solid var(--ab-border, #e2e8f0);
}
.ab-mp-search {
width: 100%; box-sizing: border-box;
padding: 10px 14px; font-size: 14px;
border: 1px solid var(--ab-border, #e2e8f0);
border-radius: 8px; outline: none;
color: var(--ab-text, #1e293b);
background: var(--ab-bg, #f8fafc);
}
.ab-mp-search:focus {
border-color: var(--ab-primary, #4f46e5);
box-shadow: 0 0 0 3px var(--ab-primary-light, #eef2ff);
background: var(--ab-card-bg, #fff);
}
/* ── Model list ─────────────────────────────────────────────────────── */
.ab-mp-list {
flex: 1; overflow-y: auto;
padding: 4px 0;
min-height: 200px; max-height: 50vh;
}
.ab-mp-loading, .ab-mp-empty {
padding: 40px 20px; text-align: center;
color: var(--ab-text-muted, #64748b); font-size: 14px;
}
/* ── Model row ──────────────────────────────────────────────────────── */
.ab-mp-row {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 20px; cursor: pointer;
transition: background 0.1s ease;
gap: 12px;
}
.ab-mp-row:hover {
background: var(--ab-primary-light, #eef2ff);
}
.ab-mp-row-main {
display: flex; align-items: center; gap: 8px;
flex: 1; min-width: 0;
}
.ab-mp-name {
font-size: 13px; font-weight: 600;
color: var(--ab-text, #1e293b);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ab-mp-provider {
font-size: 11px; font-weight: 500;
color: var(--ab-primary, #4f46e5);
background: var(--ab-primary-light, #eef2ff);
padding: 1px 6px; border-radius: 4px;
white-space: nowrap; flex-shrink: 0;
}
.ab-mp-dep-badge {
font-size: 10px; font-weight: 600;
color: var(--ab-warning, #d97706);
background: #fffbeb;
padding: 1px 6px; border-radius: 4px;
white-space: nowrap; flex-shrink: 0;
}
.ab-mp-deprecated {
opacity: 0.55;
}
.ab-mp-row-meta {
display: flex; align-items: center; gap: 16px;
flex-shrink: 0;
}
.ab-mp-price {
font-size: 12px; font-weight: 500;
color: var(--ab-text-muted, #64748b);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ab-mp-ctx {
font-size: 11px; font-weight: 600;
color: var(--ab-text-muted, #64748b);
background: var(--ab-bg, #f8fafc);
padding: 2px 6px; border-radius: 4px;
white-space: nowrap; min-width: 36px; text-align: center;
}
/* ── Footer ─────────────────────────────────────────────────────────── */
.ab-mp-footer {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 20px;
border-top: 1px solid var(--ab-border, #e2e8f0);
font-size: 12px; color: var(--ab-text-muted, #64748b);
}
/* ── Trigger button (replaces text input) ───────────────────────────── */
.ab-mp-trigger {
display: inline-flex; align-items: center; gap: 10px;
padding: 8px 14px;
background: var(--ab-card-bg, #fff);
border: 1px solid var(--ab-border, #e2e8f0);
border-radius: 6px; cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
max-width: 420px; width: 100%; box-sizing: border-box;
text-align: left;
}
.ab-mp-trigger:hover {
border-color: var(--ab-primary, #4f46e5);
}
.ab-mp-trigger:focus {
border-color: var(--ab-primary, #4f46e5);
box-shadow: 0 0 0 3px var(--ab-primary-light, #eef2ff);
outline: none;
}
.ab-mp-display-name {
font-size: 13px; font-weight: 600;
color: var(--ab-text, #1e293b);
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ab-mp-display-price {
font-size: 11px; font-weight: 500;
color: var(--ab-text-muted, #64748b);
white-space: nowrap; flex-shrink: 0;
}
.ab-mp-display-arrow {
color: var(--ab-text-muted, #64748b);
font-size: 14px; flex-shrink: 0;
}
/* ── Responsive ─────────────────────────────────────────────────────── */
@media screen and (max-width: 680px) {
.ab-mp-popup { width: 95vw; max-height: 85vh; }
.ab-mp-row { padding: 10px 14px; }
.ab-mp-row-meta { gap: 8px; }
}

View File

@@ -36,14 +36,6 @@
url.searchParams.set('tab', tab);
window.history.replaceState({}, '', url);
}
// Update the _wp_http_referer so WordPress redirects back to this tab after save.
var $referer = $('#ab-settings-form input[name="_wp_http_referer"]');
if ($referer.length) {
var refUrl = new URL($referer.val(), window.location.origin);
refUrl.searchParams.set('tab', tab);
$referer.val(refUrl.pathname + refUrl.search);
}
});
/*
@@ -63,37 +55,6 @@
$notice.fadeOut(400, function () { $(this).remove(); });
}, 6000);
}
// On load, sync _wp_http_referer with the current tab so the first
// save without switching tabs still returns to the right place.
var currentTab = new URL(window.location).searchParams.get('tab');
if (currentTab) {
var $referer = $('#ab-settings-form input[name="_wp_http_referer"]');
if ($referer.length) {
var refUrl = new URL($referer.val(), window.location.origin);
refUrl.searchParams.set('tab', currentTab);
$referer.val(refUrl.pathname + refUrl.search);
}
}
// Resume polling if a generation is already running (e.g., user
// navigated away and came back, or opened a new tab mid-generation).
var $genBtn = $('#prautoblogger-generate-now');
if ($genBtn.length && config.ajaxUrl) {
$.ajax({
url: config.ajaxUrl,
method: 'POST',
data: { action: 'prautoblogger_generation_status', nonce: config.generateNonce },
timeout: 10000,
success: function (response) {
if (response.success && response.data && response.data.status === 'running') {
setButtonLoading($genBtn, config.generatingText || 'Generating…');
showProgress(response.data.stage || 'Generation in progress…');
pollGenerationStatus($genBtn);
}
}
});
}
});
/*
@@ -165,76 +126,29 @@
* Shows pipeline stages while the request is in-flight so the user
* knows it hasn't hung.
*/
/*
* Pipeline stages are now reported by the server via the status polling
* endpoint. The old client-side fake timers have been removed.
*/
/** Poll interval handle for generation status checks. */
var statusPollTimer = null;
/**
* Poll the generation status endpoint until the run finishes.
* Updates the progress UI and resolves the button state on completion.
*
* @param {jQuery} $btn The Generate Now button.
*/
function pollGenerationStatus($btn) {
statusPollTimer = setInterval(function () {
$.ajax({
url: config.ajaxUrl,
method: 'POST',
data: {
action: 'prautoblogger_generation_status',
nonce: config.generateNonce
},
timeout: 15000,
success: function (response) {
if (!response.success) return;
var d = response.data;
if (d.status === 'running') {
showProgress(d.stage || 'Generating…');
return; // Keep polling.
}
// Terminal state — stop polling.
clearInterval(statusPollTimer);
statusPollTimer = null;
hideProgress();
resetButton($btn, config.generateText || 'Generate Now');
if (d.status === 'complete') {
showStatus(
'Generation complete: ' + d.generated + ' generated, ' +
d.published + ' published, ' + d.rejected + ' rejected. ' +
'Cost: $' + parseFloat(d.cost).toFixed(4),
'success'
);
} else if (d.status === 'error') {
showStatus(
'Generation failed: ' + (d.message || 'Unknown error'),
'error'
);
} else {
// idle or unknown — generation may have finished before
// polling started. Reset the button cleanly.
hideProgress();
}
},
error: function () {
// Network blip — keep polling, don't bail on transient errors.
}
});
}, 5000); // Poll every 5 seconds.
}
var generateStages = [
{ text: 'Collecting sources from Reddit…', delay: 0 },
{ text: 'Analyzing topics and scoring…', delay: 8000 },
{ text: 'Selecting best topic…', delay: 16000 },
{ text: 'Generating article draft via AI…', delay: 24000 },
{ text: 'Running editorial pass…', delay: 50000 },
{ text: 'Saving and publishing…', delay: 75000 },
{ text: 'Almost done — finalizing…', delay: 100000 }
];
var stageTimers = [];
$(document).on('click', '#prautoblogger-generate-now', function () {
var $btn = $(this);
if ($btn.prop('disabled')) return;
setButtonLoading($btn, config.generatingText || 'Generating…');
showProgress('Starting generation…');
// Start pipeline stage messages.
stageTimers = [];
$.each(generateStages, function (i, stage) {
var t = setTimeout(function () { showProgress(stage.text); }, stage.delay);
stageTimers.push(t);
});
$.ajax({
url: config.ajaxUrl,
@@ -244,19 +158,21 @@
nonce: config.generateNonce,
force: '1' // Always clear stale locks on manual runs.
},
timeout: 30000, // 30s is plenty — this just schedules the cron.
timeout: 300000, // 5 minutes — generation can be slow.
success: function (response) {
if (response.success) {
// Generation kicked off in background — start polling.
showProgress(response.data.message || 'Generation started…');
pollGenerationStatus($btn);
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'
);
hideProgress();
resetButton($btn, config.generateText || 'Generate Now');
}
},
error: function (xhr, status) {
@@ -264,6 +180,11 @@
'Request failed: ' + status + '. Check server error logs.',
'error'
);
},
complete: function () {
// Clear all stage timers.
$.each(stageTimers, function (_, t) { clearTimeout(t); });
stageTimers = [];
hideProgress();
resetButton($btn, config.generateText || 'Generate Now');
}

View File

@@ -1,157 +0,0 @@
/**
* PRAutoBlogger Ideas Browser — "Generate Article" button handler.
*
* Each idea row has a Generate button that triggers single-article generation
* via AJAX → WP-Cron background job. Polls for status and shows stage updates
* in the button cell, then swaps to a "View" link on completion.
*
* @see admin/class-ideas-browser.php — AJAX handlers.
* @see templates/admin/ideas-browser.php — Button markup.
*/
(function ($) {
'use strict';
var config = window.prautobloggerAdmin || {};
var pollTimers = {};
/**
* Kick off generation for an idea and start polling.
*
* @param {jQuery} $cell The table cell containing the button.
* @param {number} ideaId Analysis result row ID.
*/
function generateFromIdea($cell, ideaId) {
$cell.html(
'<span class="dashicons dashicons-update ab-spin"></span> ' +
'<span class="prab-idea-stage">Starting…</span>'
);
$.ajax({
url: config.ajaxUrl,
method: 'POST',
data: {
action: 'prautoblogger_generate_from_idea',
nonce: config.ideaGenNonce,
idea_id: ideaId
},
timeout: 15000,
success: function (response) {
if (response.success) {
pollIdeaStatus($cell, ideaId);
} else {
showIdeaError($cell, ideaId, response.data && response.data.message || 'Failed to start.');
}
},
error: function () {
showIdeaError($cell, ideaId, 'Request failed.');
}
});
}
/**
* Poll the per-idea status endpoint until generation completes.
*
* @param {jQuery} $cell The table cell to update.
* @param {number} ideaId Analysis result row ID.
*/
function pollIdeaStatus($cell, ideaId) {
pollTimers[ideaId] = setInterval(function () {
$.ajax({
url: config.ajaxUrl,
method: 'POST',
data: {
action: 'prautoblogger_idea_gen_status',
nonce: config.ideaGenNonce,
idea_id: ideaId
},
timeout: 10000,
success: function (response) {
if (!response.success) return;
var d = response.data;
if (d.status === 'running') {
$cell.find('.prab-idea-stage').text(d.stage || 'Generating…');
return;
}
// Terminal — stop polling.
clearInterval(pollTimers[ideaId]);
delete pollTimers[ideaId];
if (d.status === 'complete') {
showIdeaComplete($cell, ideaId, d);
} else if (d.status === 'error') {
showIdeaError($cell, ideaId, d.message || 'Generation failed.');
} else {
showIdeaError($cell, ideaId, 'Unknown status.');
}
}
});
}, 4000);
}
/**
* Show the completion state with a View link.
*
* @param {jQuery} $cell The table cell.
* @param {number} ideaId Analysis result row ID.
* @param {Object} data Server response data.
*/
function showIdeaComplete($cell, ideaId, data) {
var html = '<span class="dashicons dashicons-yes-alt" style="color:#00a32a;"></span> ';
html += '<span style="color:#00a32a;">Done</span>';
if (data.post_id) {
var editUrl = config.adminUrl + 'post.php?post=' + data.post_id + '&action=edit';
var viewUrl = config.siteUrl + '?p=' + data.post_id;
html += ' &mdash; <a href="' + editUrl + '">Edit</a>';
html += ' | <a href="' + viewUrl + '" target="_blank">View</a>';
}
if (data.cost > 0) {
html += '<div style="font-size:11px;color:#666;margin-top:2px;">$' + parseFloat(data.cost).toFixed(4) + '</div>';
}
$cell.html(html);
}
/**
* Show an error state with a retry button.
*
* @param {jQuery} $cell The table cell.
* @param {number} ideaId Analysis result row ID.
* @param {string} message Error message.
*/
function showIdeaError($cell, ideaId, message) {
$cell.html(
'<span class="dashicons dashicons-warning" style="color:#d63638;"></span> ' +
'<span style="color:#d63638; font-size:12px;">' + $('<span>').text(message).html() + '</span>' +
'<br><button type="button" class="button button-small prab-gen-idea-btn" data-idea-id="' + ideaId + '" style="margin-top:4px;">Retry</button>'
);
}
// Click handler for Generate / Retry buttons (event delegation).
$(document).on('click', '.prab-gen-idea-btn', function () {
var $btn = $(this);
if ($btn.prop('disabled')) return;
var ideaId = $btn.data('idea-id');
var $cell = $btn.closest('td');
generateFromIdea($cell, ideaId);
});
// On page load, resume polling for any ideas that are mid-generation.
$(function () {
$('.prab-idea-gen-cell[data-status="running"]').each(function () {
var $cell = $(this);
var ideaId = $cell.data('idea-id');
$cell.html(
'<span class="dashicons dashicons-update ab-spin"></span> ' +
'<span class="prab-idea-stage">Resuming…</span>'
);
pollIdeaStatus($cell, ideaId);
});
});
})(jQuery);

View File

@@ -1,285 +0,0 @@
/**
* Model picker popup for PRAutoBlogger admin settings.
*
* Fetches the OpenRouter model registry via AJAX, then renders a searchable
* popup overlay when the user clicks a model-select trigger button. Shows
* name, provider, input/output cost, and context window per model.
*
* @see admin/class-admin-page.php — Renders model_select fields and localizes config.
* @see services/class-open-router-model-registry.php — Server-side registry source.
*/
(function ($) {
'use strict';
var config = window.prautobloggerAdmin || {};
var cachedModels = null;
var fetchInProgress = null;
/**
* Fetch models from the server (cached after first call).
*
* @param {string} capability Filter capability (e.g. 'text→text').
* @return {jQuery.Deferred} Resolves with model array.
*/
function fetchModels(capability) {
// Image models are embedded in the page config — no AJAX needed.
if (capability === 'image_generation') {
var imgModels = config.imageModels || [];
return $.Deferred().resolve(imgModels);
}
if (cachedModels) {
return $.Deferred().resolve(filterModels(cachedModels, capability));
}
if (fetchInProgress) {
return fetchInProgress.then(function () {
return filterModels(cachedModels, capability);
});
}
fetchInProgress = $.ajax({
url: config.ajaxUrl,
method: 'POST',
data: {
action: 'prautoblogger_get_models',
nonce: config.modelsNonce
},
timeout: 15000
}).then(function (response) {
fetchInProgress = null;
if (response.success && Array.isArray(response.data)) {
cachedModels = response.data;
return filterModels(cachedModels, capability);
}
cachedModels = [];
return [];
}, function () {
fetchInProgress = null;
cachedModels = [];
return [];
});
return fetchInProgress;
}
/**
* Filter model array by capability.
*
* @param {Array} models Full model list.
* @param {string} capability Capability string to match.
* @return {Array} Filtered models.
*/
function filterModels(models, capability) {
if (!capability) return models;
return models.filter(function (m) {
return m.capabilities && m.capabilities.indexOf(capability) !== -1;
});
}
/**
* Format a price-per-million-tokens value for display.
*
* @param {number} price Price per 1M tokens.
* @return {string} Formatted string like "$0.25" or "Free".
*/
function formatPrice(price) {
if (!price || price <= 0) return 'Free';
if (price < 0.01) return '$' + price.toFixed(4);
if (price < 1) return '$' + price.toFixed(2);
return '$' + price.toFixed(2);
}
/**
* Format context length for display.
*
* @param {number} ctx Context length in tokens.
* @return {string} Formatted string like "128K" or "1M".
*/
function formatContext(ctx) {
if (!ctx) return '—';
if (ctx >= 1000000) return (ctx / 1000000).toFixed(0) + 'M';
if (ctx >= 1000) return Math.round(ctx / 1000) + 'K';
return ctx.toString();
}
/**
* Build HTML for a single model row in the picker list.
*
* @param {Object} model Normalized model record.
* @return {string} HTML string.
*/
function renderModelRow(model) {
var deprecated = model.deprecated ? ' ab-mp-deprecated' : '';
var isImageModel = model.cost_per_image !== undefined && model.cost_per_image > 0;
var priceHtml, priceTitle, metaRight;
if (isImageModel) {
priceHtml = '$' + model.cost_per_image.toFixed(4) + '/image';
priceTitle = 'Cost per generated image';
metaRight = model.description ? '<span class="ab-mp-ctx" title="Details">' + escHtml(model.description) + '</span>' : '';
} else {
priceHtml = formatPrice(model.input_price_per_m) + ' / ' + formatPrice(model.output_price_per_m);
priceTitle = 'Input / Output per 1M tokens';
metaRight = '<span class="ab-mp-ctx" title="Context window">' + formatContext(model.context_length) + '</span>';
}
return (
'<div class="ab-mp-row' + deprecated + '" data-model-id="' + escAttr(model.id) + '">' +
'<div class="ab-mp-row-main">' +
'<span class="ab-mp-name">' + escHtml(model.name) + '</span>' +
'<span class="ab-mp-provider">' + escHtml(model.provider) + '</span>' +
(model.deprecated ? '<span class="ab-mp-dep-badge">deprecated</span>' : '') +
'</div>' +
'<div class="ab-mp-row-meta">' +
'<span class="ab-mp-price" title="' + priceTitle + '">' + priceHtml + '</span>' +
metaRight +
'</div>' +
'</div>'
);
}
/** Minimal HTML escaping for display. */
function escHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** Escape a string for use in an HTML attribute. */
function escAttr(str) {
return escHtml(str).replace(/"/g, '&quot;');
}
/**
* Open the model picker popup for a given trigger button.
*
* @param {jQuery} $trigger The .ab-mp-trigger button that was clicked.
*/
function openPicker($trigger) {
var fieldId = $trigger.data('field-id');
var capability = $trigger.data('capability') || '';
// Build overlay + popup structure.
var isImageCap = capability === 'image_generation';
var priceLabel = isImageCap ? 'Cost per generated image' : 'Prices per 1M tokens (in / out)';
var $overlay = $('<div class="ab-mp-overlay"></div>');
var $popup = $(
'<div class="ab-mp-popup">' +
'<div class="ab-mp-header">' +
'<span class="ab-mp-title">Select Model</span>' +
'<button type="button" class="ab-mp-close">&times;</button>' +
'</div>' +
'<div class="ab-mp-search-wrap">' +
'<input type="text" class="ab-mp-search" placeholder="Search models…" autocomplete="off" />' +
'</div>' +
'<div class="ab-mp-list"><div class="ab-mp-loading">Loading models…</div></div>' +
'<div class="ab-mp-footer">' +
'<span class="ab-mp-count"></span>' +
'<span class="ab-mp-price-label">' + priceLabel + '</span>' +
'</div>' +
'</div>'
);
$overlay.append($popup);
$('body').append($overlay);
var $search = $popup.find('.ab-mp-search');
var $list = $popup.find('.ab-mp-list');
var $count = $popup.find('.ab-mp-count');
var allModels = [];
// Focus search after render.
setTimeout(function () { $search.focus(); }, 50);
// Fetch and render model list.
fetchModels(capability).then(function (models) {
allModels = models;
renderList(models, '');
});
/** Render filtered model list. */
function renderList(models, query) {
var filtered = models;
if (query) {
var q = query.toLowerCase();
filtered = models.filter(function (m) {
return (m.name && m.name.toLowerCase().indexOf(q) !== -1) ||
(m.id && m.id.toLowerCase().indexOf(q) !== -1) ||
(m.provider && m.provider.toLowerCase().indexOf(q) !== -1);
});
}
// Sort: non-deprecated first, then by provider + name.
filtered.sort(function (a, b) {
if (a.deprecated !== b.deprecated) return a.deprecated ? 1 : -1;
var pa = (a.provider || '').toLowerCase();
var pb = (b.provider || '').toLowerCase();
if (pa !== pb) return pa < pb ? -1 : 1;
return (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase());
});
if (filtered.length === 0) {
$list.html('<div class="ab-mp-empty">No models found.</div>');
} else {
var html = '';
for (var i = 0; i < filtered.length; i++) {
html += renderModelRow(filtered[i]);
}
$list.html(html);
}
$count.text(filtered.length + ' model' + (filtered.length !== 1 ? 's' : ''));
}
// Search input handler (debounced).
var searchTimer = null;
$search.on('input', function () {
var q = $(this).val();
clearTimeout(searchTimer);
searchTimer = setTimeout(function () {
renderList(allModels, q);
}, 150);
});
// Select a model.
$list.on('click', '.ab-mp-row', function () {
var id = $(this).data('model-id');
var model = allModels.find(function (m) { return m.id === id; });
if (!model) return;
// Update hidden input + display.
$('#' + fieldId).val(model.id);
$trigger.find('.ab-mp-display-name').text(model.name || model.id);
var isImageModel = model.cost_per_image !== undefined && model.cost_per_image > 0;
var priceText = isImageModel
? '$' + model.cost_per_image.toFixed(4) + '/image'
: formatPrice(model.input_price_per_m) + ' / ' + formatPrice(model.output_price_per_m);
$trigger.find('.ab-mp-display-price').text(priceText);
closePicker();
});
// Close handlers.
function closePicker() {
$overlay.remove();
}
$popup.find('.ab-mp-close').on('click', closePicker);
$overlay.on('click', function (e) {
if ($(e.target).is('.ab-mp-overlay')) closePicker();
});
$(document).on('keydown.abModelPicker', function (e) {
if (e.key === 'Escape') {
closePicker();
$(document).off('keydown.abModelPicker');
}
});
}
// Bind click on all model picker triggers.
$(document).on('click', '.ab-mp-trigger', function (e) {
e.preventDefault();
openPicker($(this));
});
})(jQuery);

View File

@@ -8,6 +8,7 @@
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"wp-phpunit/wp-phpunit": "^6.0",
"brain/monkey": "^2.6"
},
"autoload": {

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
* Displays admin notices for onboarding, errors, and budget warnings.
*
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_notices`.
* Dependencies: PRAutoBlogger_Cost_Reporter (for budget warnings).
* Dependencies: PRAutoBlogger_Cost_Tracker (for budget warnings).
*
* @see class-prautoblogger.php — Registers the hook.
*/
@@ -51,8 +51,8 @@ class PRAutoBlogger_Admin_Notices {
* @return void
*/
private function check_budget_notice(): void {
$cost_reporter = new PRAutoBlogger_Cost_Reporter();
$utilization = $cost_reporter->get_budget_utilization();
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
$utilization = $cost_tracker->get_budget_utilization();
if ( $utilization >= 100.0 ) {
printf(

View File

@@ -58,7 +58,6 @@ class PRAutoBlogger_Admin_Page {
'toplevel_page_' . self::PAGE_SLUG,
'prautoblogger_page_prautoblogger-metrics',
'prautoblogger_page_prautoblogger-review-queue',
'prautoblogger_page_prautoblogger-ideas',
'prautoblogger_page_prautoblogger-logs',
];
if ( ! in_array( $hook_suffix, $pages, true ) ) {
@@ -66,32 +65,18 @@ class PRAutoBlogger_Admin_Page {
}
wp_enqueue_style( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/admin.css', [], PRAUTOBLOGGER_VERSION );
wp_enqueue_style( 'prautoblogger-model-picker', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/model-picker.css', [ 'prautoblogger-admin' ], PRAUTOBLOGGER_VERSION );
wp_enqueue_script( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/admin.js', [ 'jquery' ], PRAUTOBLOGGER_VERSION, true );
wp_enqueue_script( 'prautoblogger-model-picker', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/model-picker.js', [ 'jquery', 'prautoblogger-admin' ], PRAUTOBLOGGER_VERSION, true );
wp_localize_script( 'prautoblogger-admin', 'prautobloggerAdmin', [
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'adminUrl' => admin_url(),
'siteUrl' => home_url( '/' ),
'generateNonce' => wp_create_nonce( 'prautoblogger_generate_now' ),
'imageNonce' => wp_create_nonce( 'prautoblogger_generate_image' ),
'testNonce' => wp_create_nonce( 'prautoblogger_test_connection' ),
'modelsNonce' => wp_create_nonce( 'prautoblogger_get_models' ),
'reviewNonce' => wp_create_nonce( 'prautoblogger_review_queue' ),
'ideaGenNonce' => wp_create_nonce( 'prautoblogger_idea_gen' ),
'generatingText' => __( 'Generating...', 'prautoblogger' ),
'generateText' => __( 'Generate Now', 'prautoblogger' ),
'testingText' => __( 'Testing...', 'prautoblogger' ),
'testText' => __( 'Test Connections', 'prautoblogger' ),
// Image models for the picker — defined in the settings class to keep this file short.
'imageModels' => PRAutoBlogger_Settings_Fields_Extended::get_image_models(),
] );
// Ideas browser: generate-from-idea JS (only on the Ideas page).
if ( 'prautoblogger_page_prautoblogger-ideas' === $hook_suffix ) {
wp_enqueue_script( 'prautoblogger-ideas-browser', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/ideas-browser.js', [ 'jquery', 'prautoblogger-admin' ], PRAUTOBLOGGER_VERSION, true );
}
}
/** Render the settings page (delegates to template). */
@@ -158,11 +143,6 @@ class PRAutoBlogger_Admin_Page {
case 'checkboxes':
$current = json_decode( (string) $value, true ) ?: [];
// Hidden input ensures an empty value is POSTed when all boxes
// are unchecked — without this, the browser omits the field
// entirely, so WordPress never fires the sanitize callback and
// the old value persists in wp_options.
printf( '<input type="hidden" name="%s" value="" />', $id );
foreach ( ( $args['options'] ?? [] ) as $v => $label ) {
$is_checked = in_array( $v, $current, true );
$disabled = strpos( $label, 'coming soon' ) !== false ? ' disabled' : '';
@@ -171,10 +151,6 @@ class PRAutoBlogger_Admin_Page {
}
break;
case 'model_select':
$this->render_model_select_field( $id, (string) $value, $args );
break;
case 'source_status':
$this->render_source_status_field();
break;
@@ -197,25 +173,58 @@ class PRAutoBlogger_Admin_Page {
}
/**
* Delegate source status rendering to the dedicated field class.
* Render the Reddit source status indicator.
*
* @see admin/fields/class-source-status-field.php
* Shows Reddit RSS (primary) and .json (fallback) availability
* with live status checks via transient cache and rate limit info.
*
* Side effects: Reads transients for cached availability status.
*
* @return void
*/
private function render_source_status_field(): void {
PRAutoBlogger_Source_Status_Field::render();
}
$last_collection = get_option( 'prautoblogger_last_collection_time', '' );
$last_source_used = get_option( 'prautoblogger_last_source_used', '' );
/**
* Delegate model picker rendering to the dedicated field class.
*
* @param string $id Field HTML id/name.
* @param string $value Current model id.
* @param array<string, mixed> $args Field definition.
*
* @see admin/fields/class-openrouter-model-field.php
*/
private function render_model_select_field( string $id, string $value, array $args ): void {
PRAutoBlogger_OpenRouter_Model_Field::render( $id, $value, $args );
echo '<div class="ab-source-status">';
// Reddit RSS — primary source.
printf(
'<div class="ab-source-row">'
. '<span class="ab-source-dot ab-status-ok"></span>'
. '<strong>%s</strong> <span class="ab-source-badge">%s</span>'
. '<span class="ab-source-label">%s</span>'
. '</div>',
esc_html__( 'Reddit RSS', 'prautoblogger' ),
esc_html__( 'Primary', 'prautoblogger' ),
esc_html__( 'Reliable from all IPs', 'prautoblogger' )
);
// Reddit .json — fallback source.
printf(
'<div class="ab-source-row">'
. '<span class="ab-source-dot ab-status-ok"></span>'
. '<strong>%s</strong> <span class="ab-source-badge ab-badge-secondary">%s</span>'
. '<span class="ab-source-label">%s</span>'
. '</div>',
esc_html__( 'Reddit .json', 'prautoblogger' ),
esc_html__( 'Fallback + Comments', 'prautoblogger' ),
esc_html__( 'May be IP-blocked on some hosts', 'prautoblogger' )
);
// Last collection info.
if ( '' !== $last_collection ) {
$time_ago = human_time_diff( (int) $last_collection, time() );
printf(
'<div class="ab-source-meta">%s <strong>%s</strong> %s</div>',
esc_html__( 'Last collection:', 'prautoblogger' ),
/* translators: %s is a human-readable time difference like "2 hours" */
esc_html( sprintf( __( '%s ago', 'prautoblogger' ), $time_ago ) ),
'' !== $last_source_used ? esc_html( sprintf( __( 'via %s', 'prautoblogger' ), $last_source_used ) ) : ''
);
}
echo '</div>';
}
/**
@@ -231,7 +240,7 @@ class PRAutoBlogger_Admin_Page {
$option_name = substr( $filter, strlen( 'sanitize_option_' ) );
}
$encrypted = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' ];
$encrypted = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json' ];
if ( in_array( $option_name, $encrypted, true ) ) {
// Empty value means password field wasn't touched — keep existing.
if ( '' === $value ) {
@@ -253,47 +262,19 @@ class PRAutoBlogger_Admin_Page {
$json_fields = [ 'prautoblogger_target_subreddits', 'prautoblogger_topic_exclusions', 'prautoblogger_enabled_sources' ];
if ( in_array( $option_name, $json_fields, true ) ) {
// PHP array from checkboxes — sanitize each item and re-encode.
if ( is_array( $value ) ) {
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $value ) ) );
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 ) );
}
$trimmed = trim( (string) $value );
// Already JSON-encoded array — decode, sanitize each item, re-encode.
// This prevents sanitize_text_field() from mangling the JSON string,
// which caused json_decode() on read to return null → empty subreddits.
if ( '[' === substr( $trimmed, 0, 1 ) ) {
$decoded = json_decode( $trimmed, true );
if ( is_array( $decoded ) ) {
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $decoded ) ) );
}
}
// Comma-separated plain text — split, sanitize, encode as JSON array.
$items = array_filter( array_map( 'trim', explode( ',', $trimmed ) ) );
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $items ) ) );
return sanitize_text_field( $value );
}
$numeric = [ 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit' ];
if ( in_array( $option_name, $numeric, true ) ) {
return is_numeric( $value ) ? $value : 0;
}
if ( 'prautoblogger_image_model' === $option_name ) {
return $this->sanitize_image_model( (string) $value );
}
return sanitize_text_field( (string) $value );
}
/** Validate model id against the registry and persist the derived provider. */
private function sanitize_image_model( string $value ): string {
$candidate = sanitize_text_field( $value );
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $candidate );
if ( '' !== $provider ) {
update_option( 'prautoblogger_image_provider', $provider );
return $candidate;
}
add_settings_error( 'prautoblogger_image_model', 'prautoblogger_image_model_unknown', sprintf( esc_html__( 'Image model "%s" is not in the registry. Keeping the previous selection.', 'prautoblogger' ), esc_html( $candidate ) ) );
return (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL );
}
}

View File

@@ -8,7 +8,7 @@ declare(strict_types=1);
* and budget utilization.
*
* Triggered by: Could be registered via wp_dashboard_setup hook.
* Dependencies: PRAutoBlogger_Cost_Reporter.
* Dependencies: PRAutoBlogger_Cost_Tracker.
*
* @see class-prautoblogger.php — Can register this on wp_dashboard_setup.
*/
@@ -37,10 +37,10 @@ class PRAutoBlogger_Dashboard_Widget {
* @return void
*/
public function render_widget(): void {
$cost_reporter = new PRAutoBlogger_Cost_Reporter();
$monthly_spend = $cost_reporter->get_monthly_spend();
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
$monthly_spend = $cost_tracker->get_monthly_spend();
$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
$utilization = $cost_reporter->get_budget_utilization();
$utilization = $cost_tracker->get_budget_utilization();
$next_run = wp_next_scheduled( 'prautoblogger_daily_generation' );

View File

@@ -1,299 +0,0 @@
<?php
declare(strict_types=1);
/**
* Admin page listing analysis results (article ideas) with per-idea generation.
*
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu`.
* Dependencies: WordPress $wpdb, Article_Worker, Cost_Tracker, Generation_Lock.
*
* @see core/class-article-worker.php — Generates articles from ideas.
* @see templates/admin/ideas-browser.php — Renders the HTML.
*/
class PRAutoBlogger_Ideas_Browser {
private const PER_PAGE = 30;
/** Transient prefix for per-idea generation status. */
private const STATUS_PREFIX = 'prab_idea_gen_';
/** How long to keep per-idea status (seconds). */
private const STATUS_TTL = 600;
/** Register the Ideas submenu page under PRAutoBlogger. */
public function on_register_menu(): void {
add_submenu_page(
'prautoblogger-settings',
__( 'Ideas', 'prautoblogger' ),
__( 'Ideas', 'prautoblogger' ),
'manage_options',
'prautoblogger-ideas',
[ $this, 'render_page' ]
);
}
/** Render the ideas browser page. */
public function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
$type_filter = isset( $_GET['type'] ) ? sanitize_key( $_GET['type'] ) : '';
$result = $this->query_ideas( $paged, $type_filter );
$rows = $result['rows'];
$total = $result['total'];
$total_pages = (int) ceil( $total / self::PER_PAGE );
$type_counts = $this->get_type_counts();
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/ideas-browser.php';
}
/**
* AJAX: trigger article generation from a specific idea.
*
* Loads the analysis result, stores it as a transient, sets a per-idea
* status transient, and schedules a one-shot cron event so the actual
* generation runs in a separate PHP process (Hostinger 120s limit).
*
* Side effects: transient writes, cron scheduling.
*/
public function on_ajax_generate_from_idea(): void {
check_ajax_referer( 'prautoblogger_idea_gen', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Insufficient permissions.' ], 403 );
return;
}
$idea_id = absint( $_POST['idea_id'] ?? 0 );
if ( $idea_id < 1 ) {
wp_send_json_error( [ 'message' => 'Invalid idea ID.' ] );
return;
}
// Build Article_Idea from the analysis row.
$idea_data = self::load_idea_data( $idea_id );
if ( null === $idea_data ) {
wp_send_json_error( [ 'message' => 'Idea not found.' ] );
return;
}
// Store idea for the cron handler and set initial "running" status.
set_transient( self::STATUS_PREFIX . 'data_' . $idea_id, $idea_data, self::STATUS_TTL );
set_transient( self::STATUS_PREFIX . $idea_id, [
'status' => 'running',
'stage' => __( 'Starting generation…', 'prautoblogger' ),
'started' => time(),
], self::STATUS_TTL );
// Schedule background cron — passes idea_id as argument.
$hook = 'prautoblogger_generate_from_idea';
if ( ! wp_next_scheduled( $hook, [ $idea_id ] ) ) {
wp_schedule_single_event( time(), $hook, [ $idea_id ] );
}
spawn_cron();
wp_remote_post(
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
[ 'timeout' => 0.01, 'blocking' => false, 'sslverify' => false ]
);
wp_send_json_success( [ 'message' => 'Generation started.' ] );
}
/**
* AJAX: return per-idea generation status for frontend polling.
*
* Side effects: none (reads transient only).
*/
public function on_ajax_idea_gen_status(): void {
check_ajax_referer( 'prautoblogger_idea_gen', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Insufficient permissions.' ], 403 );
return;
}
$idea_id = absint( $_POST['idea_id'] ?? 0 );
$status = get_transient( self::STATUS_PREFIX . $idea_id );
if ( ! is_array( $status ) ) {
wp_send_json_success( [ 'status' => 'idle' ] );
return;
}
wp_send_json_success( $status );
}
/**
* Cron handler: generate a single article from a stored idea.
*
* Runs in a background PHP process. Acquires the generation lock,
* runs Article_Worker, and writes the result to the per-idea status transient.
*
* Side effects: LLM API calls, DB writes, post creation, cost logging.
*
* @param int $idea_id Analysis result row ID.
*/
public static function on_cron_generate_from_idea( int $idea_id ): void {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@ignore_user_abort( true );
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( 300 );
$key = self::STATUS_PREFIX . $idea_id;
$idea_data = get_transient( self::STATUS_PREFIX . 'data_' . $idea_id );
if ( ! is_array( $idea_data ) ) {
set_transient( $key, [ 'status' => 'error', 'message' => 'Idea data expired.' ], self::STATUS_TTL );
return;
}
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
set_transient( $key, [ 'status' => 'error', 'message' => 'Another generation is running.' ], self::STATUS_TTL );
return;
}
try {
$idea = new PRAutoBlogger_Article_Idea( $idea_data );
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
$cost_tracker->set_run_id( wp_generate_uuid4() );
self::update_idea_stage( $idea_id, __( 'Generating article draft…', 'prautoblogger' ) );
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
$result = $worker->generate( $idea );
// Amortize any research costs for this single-article run.
PRAutoBlogger_Post_Assembler::amortize_research_costs( $cost_tracker->get_run_id() );
// Find the generated post ID for the "View" link.
$post_id = self::find_post_by_run_id( $cost_tracker->get_run_id() );
set_transient( $key, [
'status' => 'complete',
'generated' => $result['generated'],
'published' => $result['published'],
'cost' => $result['cost'],
'post_id' => $post_id,
], self::STATUS_TTL );
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Idea generation %s for #%d: %s', get_class( $e ), $idea_id, $e->getMessage() ),
'pipeline'
);
set_transient( $key, [ 'status' => 'error', 'message' => $e->getMessage() ], self::STATUS_TTL );
}
PRAutoBlogger_Generation_Lock::release();
delete_transient( self::STATUS_PREFIX . 'data_' . $idea_id );
}
// ── Private helpers ─────────────────────────────────────────────────
/** Update the per-idea status transient with a stage message. */
private static function update_idea_stage( int $idea_id, string $stage ): void {
$key = self::STATUS_PREFIX . $idea_id;
$current = get_transient( $key );
if ( is_array( $current ) ) {
$current['stage'] = $stage;
set_transient( $key, $current, self::STATUS_TTL );
}
}
/** Find the most recent post created by a specific run_id. */
private static function find_post_by_run_id( string $run_id ): ?int {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL LIMIT 1",
$run_id
)
);
return null !== $post_id ? (int) $post_id : null;
}
/**
* Load an analysis result row and map it to Article_Idea constructor data.
*
* @param int $idea_id Analysis result row ID.
* @return array<string, mixed>|null Article_Idea-compatible array, or null.
*/
private static function load_idea_data( int $idea_id ): ?array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$row = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $idea_id ),
ARRAY_A
);
if ( ! $row ) {
return null;
}
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?: [];
return [
'topic' => $row['topic'],
'article_type' => $row['analysis_type'],
'suggested_title' => $meta['suggested_title'] ?? $row['topic'],
'summary' => $row['summary'] ?? '',
'score' => (float) $row['relevance_score'],
'analysis_id' => (int) $row['id'],
'source_ids' => json_decode( $row['source_ids_json'] ?? '[]', true ) ?: [],
'key_points' => $meta['key_points'] ?? [],
'target_keywords' => $meta['target_keywords'] ?? [],
];
}
/** Query analysis results with optional filtering and pagination. */
private function query_ideas( int $paged, string $type ): array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
$where = [];
$params = [];
if ( '' !== $type ) {
$where[] = 'analysis_type = %s';
$params[] = $type;
}
$where_sql = empty( $where ) ? '' : 'WHERE ' . implode( ' AND ', $where );
$offset = ( $paged - 1 ) * self::PER_PAGE;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$total = (int) $wpdb->get_var(
empty( $params )
? "SELECT COUNT(*) FROM {$table} {$where_sql}"
: $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params )
);
$order_sql = 'ORDER BY analyzed_at DESC, relevance_score DESC';
$limit_sql = sprintf( 'LIMIT %d OFFSET %d', self::PER_PAGE, $offset );
$full_sql = "SELECT * FROM {$table} {$where_sql} {$order_sql} {$limit_sql}";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = empty( $params )
? $wpdb->get_results( $full_sql, ARRAY_A )
: $wpdb->get_results( $wpdb->prepare( $full_sql, ...$params ), ARRAY_A );
return [ 'rows' => $rows ?: [], 'total' => $total ];
}
/** Get counts per analysis_type for the filter badges. */
private function get_type_counts(): array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = $wpdb->get_results(
"SELECT analysis_type, COUNT(*) AS cnt FROM {$table} GROUP BY analysis_type ORDER BY cnt DESC",
ARRAY_A
);
$counts = [];
foreach ( $rows ?: [] as $row ) {
$counts[ $row['analysis_type'] ] = (int) $row['cnt'];
}
return $counts;
}
}

View File

@@ -1,111 +0,0 @@
<?php
declare(strict_types=1);
/**
* Static registry of available image generation models.
*
* What: Returns a hardcoded list of image models for the admin model picker.
* No API discovery — updated manually when providers add/remove models.
* Who calls it: PRAutoBlogger_Admin_Page (model picker UI + save-time provider
* derivation) and PRAutoBlogger_Image_Pipeline (provider lookup).
* Dependencies: None.
*
* @see admin/class-settings-fields-extended.php — Image settings reference this registry.
* @see core/class-image-pipeline.php — Derives provider from picked model.
* @see providers/class-runware-image-provider.php — Runware FLUX backend.
*/
class PRAutoBlogger_Image_Model_Registry {
/**
* Get all available image generation models.
*
* Each entry contains:
* - id: Model identifier used in API calls.
* - name: Human-readable display name.
* - provider: 'runware' | 'openrouter'.
* - cost_per_image: Estimated USD cost per generation.
* - capabilities: Array of capability tags.
* - description: Short description for the admin UI.
*
* Ordered cheapest-to-most-expensive within each provider, with the
* recommended default (Runware schnell) at the top.
*
* @return array<int, array<string, mixed>>
*/
public static function get_models(): array {
return [
[
'id' => 'runware:100@1',
'name' => 'FLUX.1 schnell (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0006,
'capabilities' => [ 'image_generation' ],
'description' => __( 'Default. Fast, very cheap, comic-friendly fidelity.', 'prautoblogger' ),
],
[
'id' => 'runware:101@1',
'name' => 'FLUX.1 dev (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.02,
'capabilities' => [ 'image_generation' ],
'description' => __( 'Higher fidelity FLUX. ~30x schnell cost.', 'prautoblogger' ),
],
[
'id' => 'google/gemini-2.5-flash-image',
'name' => 'Gemini 2.5 Flash Image (Nano Banana)',
'provider' => 'openrouter',
'cost_per_image' => 0.005,
'capabilities' => [ 'image_generation' ],
'description' => __( 'Mid-tier quality, proven.', 'prautoblogger' ),
],
[
'id' => 'google/gemini-3.1-flash-image-preview',
'name' => 'Gemini 3.1 Flash Image (Nano Banana 2)',
'provider' => 'openrouter',
'cost_per_image' => 0.008,
'capabilities' => [ 'image_generation' ],
'description' => __( 'Latest Google. Better quality.', 'prautoblogger' ),
],
[
'id' => 'google/gemini-3-pro-image-preview',
'name' => 'Gemini 3 Pro Image (Nano Banana Pro)',
'provider' => 'openrouter',
'cost_per_image' => 0.03,
'capabilities' => [ 'image_generation' ],
'description' => __( 'Highest quality Google. Premium.', 'prautoblogger' ),
],
[
'id' => 'openai/gpt-5-image-mini',
'name' => 'GPT-5 Image Mini',
'provider' => 'openrouter',
'cost_per_image' => 0.02,
'capabilities' => [ 'image_generation' ],
'description' => __( 'OpenAI budget image model.', 'prautoblogger' ),
],
[
'id' => 'openai/gpt-5-image',
'name' => 'GPT-5 Image',
'provider' => 'openrouter',
'cost_per_image' => 0.08,
'capabilities' => [ 'image_generation' ],
'description' => __( 'OpenAI premium. Photorealistic.', 'prautoblogger' ),
],
];
}
/**
* Return the provider id for a known model, or empty string if the
* model id is not in the registry.
*
* @param string $model_id Model slug from the admin UI.
* @return string Provider id ('runware' | 'openrouter' | '').
*/
public static function provider_for( string $model_id ): string {
foreach ( self::get_models() as $model ) {
if ( ( $model['id'] ?? '' ) === $model_id ) {
return (string) ( $model['provider'] ?? '' );
}
}
return '';
}
}

View File

@@ -1,261 +0,0 @@
<?php
declare(strict_types=1);
/**
* Adds "Writing Model", "Image Model", and "Cost" columns to the Posts list.
*
* Shows which LLM wrote each article, which model generated its featured image,
* and the total API cost of generating the article. Non-generated posts show "—".
*
* Triggered by: PRAutoBlogger::register_admin_hooks().
* Dependencies: Post meta, attachment meta, prab_generation_log table.
*
* @see core/class-publisher.php — Sets _prautoblogger_model_used.
* @see core/class-image-media-sideloader.php — Sets _prautoblogger_image_model on attachment.
* @see core/class-cost-tracker.php — Writes generation_log with estimated_cost.
* @see class-prautoblogger.php — Registers hooks for this class.
*/
class PRAutoBlogger_Post_List_Columns {
/**
* Register column and render hooks.
*
* Side effects: adds WordPress filters/actions for post list customization.
*/
public function register(): void {
add_filter( 'manage_posts_columns', [ $this, 'filter_add_columns' ] );
add_action( 'manage_posts_custom_column', [ $this, 'on_render_column' ], 10, 2 );
add_filter( 'manage_edit-post_sortable_columns', [ $this, 'filter_sortable_columns' ] );
add_action( 'pre_get_posts', [ $this, 'on_sort_by_model' ] );
add_action( 'admin_head', [ $this, 'on_column_styles' ] );
}
/**
* Insert three columns after the title column.
*
* @param array<string, string> $columns Existing columns.
* @return array<string, string> Modified columns.
*/
public function filter_add_columns( array $columns ): array {
$new = [];
foreach ( $columns as $key => $label ) {
$new[ $key ] = $label;
if ( 'title' === $key ) {
$new['prab_writing_model'] = __( 'Writing Model', 'prautoblogger' );
$new['prab_image_model'] = __( 'Image Model', 'prautoblogger' );
$new['prab_cost'] = __( 'Cost', 'prautoblogger' );
}
}
return $new;
}
/**
* Render cell content for our custom columns.
*
* @param string $column Column identifier.
* @param int $post_id Current post ID.
*/
public function on_render_column( string $column, int $post_id ): void {
switch ( $column ) {
case 'prab_writing_model':
$this->render_model_cell(
(string) get_post_meta( $post_id, '_prautoblogger_model_used', true )
);
break;
case 'prab_image_model':
$this->render_image_model_cell( $post_id );
break;
case 'prab_cost':
$this->render_cost_cell( $post_id );
break;
}
}
/**
* Make the writing model column sortable.
*
* @param array<string, string> $columns Sortable column map.
* @return array<string, string>
*/
public function filter_sortable_columns( array $columns ): array {
$columns['prab_writing_model'] = 'prab_writing_model';
return $columns;
}
/**
* Handle sorting by writing model meta value.
*
* @param \WP_Query $query The admin posts query.
*/
public function on_sort_by_model( \WP_Query $query ): void {
if ( ! is_admin() || ! $query->is_main_query() ) {
return;
}
if ( 'prab_writing_model' !== $query->get( 'orderby' ) ) {
return;
}
$query->set( 'meta_key', '_prautoblogger_model_used' );
$query->set( 'orderby', 'meta_value' );
}
/**
* Output column width styles on the Posts list screen only.
*/
public function on_column_styles(): void {
$screen = get_current_screen();
if ( ! $screen || 'edit-post' !== $screen->id ) {
return;
}
echo '<style>'
. '.wp-list-table .column-title{width:35%;word-wrap:break-word;overflow-wrap:break-word}'
. '.column-prab_writing_model{width:100px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
. '.column-prab_image_model{width:100px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
. '.column-prab_cost{width:55px;white-space:nowrap;overflow:visible}'
. '.prab-cost-wrap{position:relative;display:inline-block}'
. '.prab-cost-trigger{font-size:12px;color:#2271b1;text-decoration:underline;cursor:pointer}'
. '.prab-cost-popover{display:none;position:absolute;right:0;top:100%;z-index:9999;'
. 'background:#fff;border:1px solid #c3c4c7;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.15);'
. 'padding:8px;min-width:340px;white-space:normal}'
. '.prab-cost-wrap:hover .prab-cost-popover,'
. '.prab-cost-trigger:focus+.prab-cost-popover{display:block}'
. '.prab-cost-table{width:100%;border-collapse:collapse;font-size:12px}'
. '.prab-cost-table th{text-align:left;border-bottom:2px solid #dcdcde;padding:3px 6px;color:#50575e;font-weight:600}'
. '.prab-cost-table td{padding:3px 6px;border-bottom:1px solid #f0f0f1;color:#3c434a}'
. '.prab-cost-table tr:last-child td{border-bottom:none}'
. '.prab-cost-total td{border-top:2px solid #dcdcde;border-bottom:none}'
. '</style>';
}
// ── Private helpers ─────────────────────────────────────────────────
/**
* Render a model name cell with shortened display and full tooltip.
*
* @param string $model Full model identifier (may be empty).
*/
private function render_model_cell( string $model ): void {
if ( '' === $model ) {
echo '—';
return;
}
printf(
'<span title="%s" style="font-size:12px;color:#666;">%s</span>',
esc_attr( $model ),
esc_html( $this->shorten( $model ) )
);
}
/**
* Look up the featured image's model and render it.
*
* @param int $post_id The post ID.
*/
private function render_image_model_cell( int $post_id ): void {
$thumbnail_id = (int) get_post_thumbnail_id( $post_id );
if ( 0 === $thumbnail_id ) {
echo '—';
return;
}
$model = (string) get_post_meta( $thumbnail_id, '_prautoblogger_image_model', true );
$this->render_model_cell( $model );
}
/**
* Fetch generation_log rows for a post, render total with clickable breakdown.
*
* @param int $post_id The post ID.
*/
private function render_cost_cell( int $post_id ): void {
$is_generated = get_post_meta( $post_id, '_prautoblogger_generated', true );
if ( '1' !== $is_generated ) {
echo '—';
return;
}
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT stage, model, prompt_tokens, completion_tokens, estimated_cost
FROM {$table} WHERE post_id = %d ORDER BY id ASC",
$post_id
),
ARRAY_A
);
if ( empty( $rows ) ) {
echo '<span style="font-size:12px;color:#999;">$0.00</span>';
return;
}
$total = array_sum( array_column( $rows, 'estimated_cost' ) );
$formatted = $total < 0.01
? sprintf( '$%.4f', $total )
: sprintf( '$%.2f', $total );
$breakdown = $this->build_breakdown_html( $rows, $total );
printf(
'<span class="prab-cost-wrap">'
. '<a href="#" class="prab-cost-trigger" onclick="return false;">%s</a>'
. '<span class="prab-cost-popover">%s</span>'
. '</span>',
esc_html( $formatted ),
$breakdown
);
}
/**
* Build the HTML breakdown table for the cost popover.
*
* @param array[] $rows Generation log rows.
* @param float $total Total cost.
* @return string HTML table (already escaped).
*/
private function build_breakdown_html( array $rows, float $total ): string {
$html = '<table class="prab-cost-table">';
$html .= '<tr><th>Stage</th><th>Model</th><th>Tokens</th><th>Cost</th></tr>';
foreach ( $rows as $row ) {
$stage = esc_html( ucfirst( str_replace( '_', ' ', $row['stage'] ) ) );
$model = esc_html( $this->shorten( $row['model'] ) );
$tokens = (int) $row['prompt_tokens'] + (int) $row['completion_tokens'];
$cost = (float) $row['estimated_cost'];
$cost_f = $cost < 0.01 ? sprintf( '$%.4f', $cost ) : sprintf( '$%.2f', $cost );
$html .= sprintf(
'<tr><td>%s</td><td title="%s">%s</td><td>%s</td><td>%s</td></tr>',
$stage,
esc_attr( $row['model'] ),
$model,
esc_html( number_format( $tokens ) ),
esc_html( $cost_f )
);
}
$total_f = $total < 0.01 ? sprintf( '$%.4f', $total ) : sprintf( '$%.2f', $total );
$html .= sprintf(
'<tr class="prab-cost-total"><td colspan="3"><strong>Total</strong></td>'
. '<td><strong>%s</strong></td></tr>',
esc_html( $total_f )
);
$html .= '</table>';
return $html;
}
/**
* Strip provider prefix for compact display.
*
* @param string $model Full model identifier.
* @return string Shortened name.
*/
private function shorten( string $model ): string {
$pos = strpos( $model, '/' );
return false !== $pos ? substr( $model, $pos + 1 ) : $model;
}
}

View File

@@ -1,261 +0,0 @@
<?php
declare(strict_types=1);
/**
* Extended settings fields: schedule, publishing, analytics, display, and images.
*
* What: Declarative field definitions for operational settings sections.
* Who calls it: PRAutoBlogger_Settings_Fields::get_fields() merges these in.
* Dependencies: PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX constants,
* PRAutoBlogger_Article_Typography (font choices), PRAutoBlogger_Image_Model_Registry.
*
* @see admin/class-settings-fields.php — Core fields and sections; calls get_extended_fields().
* @see admin/class-admin-page.php — Renders fields registered from these definitions.
* @see admin/class-image-model-registry.php — Static image model list extracted for 300-line compliance.
* @see frontend/class-article-typography.php — Reads Display settings on the frontend.
* @see CONVENTIONS.md — "How To: Add a New Admin Setting".
*/
class PRAutoBlogger_Settings_Fields_Extended {
/**
* Static image model list for the admin model picker.
* Delegates to Image_Model_Registry (extracted for 300-line file limit).
*
* @return array<int, array<string, mixed>>
*/
public static function get_image_models(): array {
return PRAutoBlogger_Image_Model_Registry::get_models();
}
/**
* Get schedule, publishing, analytics, and image settings fields.
*
* @return array<int, array<string, mixed>>
*/
public static function get_fields(): array {
return [
// ── Sources (extended) ──────────────────────────────────────
[
'id' => 'prautoblogger_research_model',
'label' => __( 'Research Model', 'prautoblogger' ),
'type' => 'model_select',
'section' => 'prautoblogger_sources',
'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
'capability' => 'text→text',
'description' => __( 'Model for LLM Deep Research. Pick a reasoning-capable model (e.g. Grok 4.1 Fast, DeepSeek-R1) for best results. Only used when LLM Deep Research is enabled above.', 'prautoblogger' ),
'badge' => __( 'Reasoning', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_research_prompt',
'label' => __( 'Research Prompt', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_sources',
'default' => 'Conduct a deep research sweep of the {niche} space and identify the most substantive, actionable topics that practitioners and curious newcomers are searching for or actively discussing.' . "\n\n" . 'Focus on:' . "\n" . '- Practical questions real readers would search for (not theoretical debates)' . "\n" . '- Emerging trends gaining momentum in the community' . "\n" . '- Common misconceptions that deserve clarification' . "\n" . '- Comparative analysis (product vs. product, protocol vs. protocol)' . "\n" . '- Safety and efficacy topics that drive concern or curiosity' . "\n" . '- Beginner-to-advanced mix — cover entry-level and depth-focused topics' . "\n\n" . 'Aim for topics that feel timely, underserved by existing content, and likely to drive reader engagement. Return 8-12 findings with detailed analysis in each.',
'description' => __( 'The research brief sent to the LLM. Use {niche} as a placeholder for your niche description. The system prompt handles output format — this controls WHAT to research.', 'prautoblogger' ),
],
// ── AI Models (extended) ────────────────────────────────────
[
'id' => 'prautoblogger_reasoning_enabled',
'label' => __( 'Enable Reasoning', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_models',
'default' => '0',
'description' => __( 'Send reasoning instructions to models that support it (e.g. Grok, DeepSeek-R1). Reasoning tokens are billed as output tokens — expect higher costs per call. Models that don\'t support reasoning will ignore this.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_reasoning_effort',
'label' => __( 'Reasoning Effort', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_models',
'default' => 'medium',
'options' => [
'xhigh' => __( 'Extra High — maximum depth, highest cost', 'prautoblogger' ),
'high' => __( 'High — thorough reasoning', 'prautoblogger' ),
'medium' => __( 'Medium — balanced (recommended)', 'prautoblogger' ),
'low' => __( 'Low — light reasoning, lower cost', 'prautoblogger' ),
'minimal' => __( 'Minimal — barely any reasoning', 'prautoblogger' ),
],
'description' => __( 'How much effort the model spends reasoning before answering. Higher effort = more reasoning tokens = higher cost. Only applies when reasoning is enabled above.', 'prautoblogger' ),
],
// ── Schedule & Budget ───────────────────────────────────────
[
'id' => 'prautoblogger_daily_article_target',
'label' => __( 'Articles per Day', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_schedule',
'default' => 1,
'min' => 1,
'max' => 10,
],
[
'id' => 'prautoblogger_schedule_time',
'label' => __( 'Generation Time', 'prautoblogger' ),
'type' => 'time',
'section' => 'prautoblogger_schedule',
'default' => '03:00',
'description' => sprintf(
/* translators: %s: site timezone string, e.g. "Asia/Singapore". */
esc_html__( "Daily generation runs at this time in your site's configured timezone (Settings → General → Timezone). Current site timezone: %s.", 'prautoblogger' ),
esc_html( function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : 'UTC' )
),
],
[
'id' => 'prautoblogger_monthly_budget_usd',
'label' => __( 'Monthly Budget (USD)', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_schedule',
'default' => 50,
'min' => 0,
'step' => '0.01',
'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ),
],
// ── Publishing ──────────────────────────────────────────────
[
'id' => 'prautoblogger_auto_publish',
'label' => __( 'Auto-Publish', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_publishing',
'default' => '0',
'description' => __( 'Automatically publish editor-approved posts. When off, all posts go to the Review Queue as drafts.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_default_author',
'label' => __( 'Default Author', 'prautoblogger' ),
'type' => 'author_select',
'section' => 'prautoblogger_publishing',
'default' => 0,
'description' => __( 'WordPress user assigned as author of generated posts.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_default_category',
'label' => __( 'Default Category', 'prautoblogger' ),
'type' => 'category_select',
'section' => 'prautoblogger_publishing',
'default' => 0,
'description' => __( 'Fallback category for generated posts.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_log_level',
'label' => __( 'Log Level', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_publishing',
'default' => 'info',
'options' => [
'error' => __( 'Error — only failures', 'prautoblogger' ),
'warning' => __( 'Warning — errors + warnings', 'prautoblogger' ),
'info' => __( 'Info — key events (recommended)', 'prautoblogger' ),
'debug' => __( 'Debug — everything (verbose)', 'prautoblogger' ),
],
'description' => __( 'Controls detail level in the Activity Log.', 'prautoblogger' ),
],
// ── Analytics ───────────────────────────────────────────────
[
'id' => 'prautoblogger_ga4_property_id',
'label' => __( 'GA4 Property ID', 'prautoblogger' ),
'type' => 'text',
'section' => 'prautoblogger_analytics',
'description' => __( 'Your GA4 Property ID — digits only, e.g. 123456789 (the code adds the "properties/" prefix internally). Find it in GA4 → Admin → Property details. Leave blank to skip GA4.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_ga4_credentials_json',
'label' => __( 'Service Account JSON', 'prautoblogger' ),
'type' => 'password',
'section' => 'prautoblogger_analytics',
'description' => __( 'Paste the full JSON key file for a service account with Analytics read access.', 'prautoblogger' ),
],
// ── Display ────────────────────────────────────────────────
[
'id' => 'prautoblogger_article_font_family',
'label' => __( 'Article Font', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_display',
'default' => 'default',
'options' => PRAutoBlogger_Article_Typography::get_font_choices(),
'description' => __( 'Font family used for generated article body text. Serif fonts improve long-form readability.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_article_font_size',
'label' => __( 'Article Font Size (px)', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_display',
'default' => 0,
'min' => 0,
'max' => 32,
'description' => __( 'Base font size for article body text. 0 uses the theme default (13px). Recommended: 16–18px for comfortable reading.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_table_borders',
'label' => __( 'Table Borders', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_display',
'default' => '1',
'description' => __( 'Add visible borders, padding, and alternating row colors to tables in generated articles.', 'prautoblogger' ),
],
// ── Images ─────────────────────────────────────────────────
[
'id' => 'prautoblogger_image_enabled',
'label' => __( 'Enable Image Generation', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '0',
'description' => __( 'Generate images for each published article.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_b_enabled',
'label' => __( 'Generate Second Image (B)', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '1',
'description' => __( 'Generate a second image from source data for A/B testing. Disabling saves one image generation + one LLM prompt rewrite per article.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_model',
'label' => __( 'Image Model', 'prautoblogger' ),
'type' => 'model_select',
'section' => 'prautoblogger_images',
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL,
'capability' => 'image_generation',
'description' => __( 'Pick an image model. The provider (Runware or OpenRouter) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ),
'badge' => __( 'Quality', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_runware_api_key',
'label' => __( 'Runware API Key', 'prautoblogger' ),
'type' => 'password',
'section' => 'prautoblogger_images',
'description' => __( 'Required when using a Runware FLUX model. Get your key at runware.ai/signup.', 'prautoblogger' ),
'icon' => '🔑',
],
[
'id' => 'prautoblogger_image_style_suffix',
'label' => __( 'Style Suffix', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_images',
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX,
'description' => __( 'Appended to every image prompt. Controls visual look. Changing mid-run causes visible style drift.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_prompt_instructions',
'label' => __( 'Image Prompt Instructions', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_images',
'default' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE + CAPTION for the image generator. Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_nsfw_retry',
'label' => __( 'Retry NSFW-Blocked Images', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '1',
'description' => __( 'When the provider rejects an image prompt as NSFW, retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ),
],
];
}
}

View File

@@ -6,15 +6,12 @@ declare(strict_types=1);
*
* 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.
* Core fields (API, Models, Content, Sources) live here; operational fields
* (Schedule, Publishing, Analytics, Images) are in Settings_Fields_Extended.
*
* Triggered by: PRAutoBlogger_Admin_Page::on_register_settings() calls static methods here.
* Dependencies: PRAutoBlogger_Settings_Fields_Extended for operational field definitions.
* Dependencies: None — contains only data definitions and static methods.
*
* @see admin/class-settings-fields-extended.php — Schedule, publishing, analytics, images fields.
* @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".
* @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 PRAutoBlogger_Settings_Fields {
@@ -60,34 +57,20 @@ class PRAutoBlogger_Settings_Fields {
'icon' => 'dashicons-chart-area',
'description' => __( 'Connect Google Analytics 4 for post performance scoring.', 'prautoblogger' ),
],
'prautoblogger_display' => [
'title' => __( 'Display', 'prautoblogger' ),
'icon' => 'dashicons-editor-textcolor',
'description' => __( 'Control how generated articles look on the frontend — fonts, sizes, and table styling.', 'prautoblogger' ),
],
'prautoblogger_images' => [
'title' => __( 'Images', 'prautoblogger' ),
'icon' => 'dashicons-format-image',
'description' => __( 'Image provider, model, and style controls. OpenRouter reuses your existing API key.', 'prautoblogger' ),
'description' => __( 'Cloudflare Workers AI credentials and image style controls.', 'prautoblogger' ),
],
];
}
/**
* Get all settings fields. Core fields here, operational fields merged from Extended.
* 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 array_merge( self::get_core_fields(), PRAutoBlogger_Settings_Fields_Extended::get_fields() );
}
/**
* Core fields: API Keys, AI Models, Content, and Sources.
*
* @return array<int, array<string, mixed>>
*/
private static function get_core_fields(): array {
return [
// ── API Keys ────────────────────────────────────────────────
[
@@ -130,30 +113,27 @@ class PRAutoBlogger_Settings_Fields {
[
'id' => 'prautoblogger_analysis_model',
'label' => __( 'Analysis Model', 'prautoblogger' ),
'type' => 'model_select',
'type' => 'text',
'section' => 'prautoblogger_models',
'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
'capability' => 'text→text',
'description' => __( 'Used for source analysis. Pick a cheap, fast model.', 'prautoblogger' ),
'badge' => __( 'Low cost', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_writing_model',
'label' => __( 'Writing Model', 'prautoblogger' ),
'type' => 'model_select',
'type' => 'text',
'section' => 'prautoblogger_models',
'default' => PRAUTOBLOGGER_DEFAULT_WRITING_MODEL,
'capability' => 'text→text',
'description' => __( 'Used for article generation. Quality matters here.', 'prautoblogger' ),
'badge' => __( 'Quality', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_editor_model',
'label' => __( 'Editor Model', 'prautoblogger' ),
'type' => 'model_select',
'type' => 'text',
'section' => 'prautoblogger_models',
'default' => PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL,
'capability' => 'text→text',
'description' => __( 'Used for the chief editor review pass.', 'prautoblogger' ),
'badge' => __( 'Quality', 'prautoblogger' ),
],
@@ -166,14 +146,6 @@ class PRAutoBlogger_Settings_Fields {
'section' => 'prautoblogger_content',
'description' => __( 'Describe your site\'s niche. Guides content analysis and generation.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_analysis_instructions',
'label' => __( 'Analysis Instructions', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_content',
'default' => '',
'description' => __( 'Custom instructions for the topic analysis LLM. Steers how source data is evaluated and which ideas get surfaced — e.g. "Prioritize questions over news. Ignore price-related discussions."', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_tone',
'label' => __( 'Content Tone', 'prautoblogger' ),
@@ -215,22 +187,6 @@ class PRAutoBlogger_Settings_Fields {
'default' => 2000,
'min' => 500,
],
[
'id' => 'prautoblogger_writing_instructions',
'label' => __( 'Writing Instructions', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_content',
'default' => '',
'description' => __( 'Custom instructions appended to the LLM\'s system prompt when writing articles. Use this to steer style, structure, voice, and formatting — e.g. "Always open with a hook question. Use short paragraphs. Cite at least two studies."', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_editor_instructions',
'label' => __( 'Editor Instructions', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_content',
'default' => '',
'description' => __( 'Custom instructions for the chief editor LLM review pass. Steers what the editor looks for and how it revises — e.g. "Be strict about medical disclaimers. Reject any article under 800 words."', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_topic_exclusions',
'label' => __( 'Topic Exclusions', 'prautoblogger' ),
@@ -253,8 +209,7 @@ class PRAutoBlogger_Settings_Fields {
'type' => 'checkboxes',
'section' => 'prautoblogger_sources',
'options' => [
'reddit' => __( 'Reddit (RSS + .json)', 'prautoblogger' ),
'llm_research' => __( 'LLM Deep Research (reasoning model)', 'prautoblogger' ),
'reddit' => __( 'Reddit (RSS + .json)', 'prautoblogger' ),
],
'default' => '["reddit"]',
'description' => __( 'Select which platforms to monitor for topics.', 'prautoblogger' ),
@@ -292,6 +247,140 @@ class PRAutoBlogger_Settings_Fields {
'max' => 100,
'description' => __( 'Maximum posts to fetch per subreddit per collection run.', 'prautoblogger' ),
],
// ── Schedule & Budget ───────────────────────────────────────
[
'id' => 'prautoblogger_daily_article_target',
'label' => __( 'Articles per Day', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_schedule',
'default' => 1,
'min' => 1,
'max' => 10,
],
[
'id' => 'prautoblogger_schedule_time',
'label' => __( 'Generation Time', 'prautoblogger' ),
'type' => 'time',
'section' => 'prautoblogger_schedule',
'default' => '03:00',
'description' => __( 'Daily generation runs at this time (server timezone).', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_monthly_budget_usd',
'label' => __( 'Monthly Budget (USD)', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_schedule',
'default' => 50,
'min' => 0,
'step' => '0.01',
'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ),
],
// ── Publishing ──────────────────────────────────────────────
[
'id' => 'prautoblogger_auto_publish',
'label' => __( 'Auto-Publish', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_publishing',
'default' => '0',
'description' => __( 'Automatically publish editor-approved posts. When off, all posts go to the Review Queue as drafts.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_default_author',
'label' => __( 'Default Author', 'prautoblogger' ),
'type' => 'author_select',
'section' => 'prautoblogger_publishing',
'default' => 0,
'description' => __( 'WordPress user assigned as author of generated posts.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_default_category',
'label' => __( 'Default Category', 'prautoblogger' ),
'type' => 'category_select',
'section' => 'prautoblogger_publishing',
'default' => 0,
'description' => __( 'Fallback category for generated posts.', 'prautoblogger' ),
],
// ── Logging ─────────────────────────────────────────────────
[
'id' => 'prautoblogger_log_level',
'label' => __( 'Log Level', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_publishing',
'default' => 'info',
'options' => [
'error' => __( 'Error — only failures', 'prautoblogger' ),
'warning' => __( 'Warning — errors + warnings', 'prautoblogger' ),
'info' => __( 'Info — key events (recommended)', 'prautoblogger' ),
'debug' => __( 'Debug — everything (verbose)', 'prautoblogger' ),
],
'description' => __( 'Controls detail level in the Activity Log.', 'prautoblogger' ),
],
// ── Analytics ───────────────────────────────────────────────
[
'id' => 'prautoblogger_ga4_property_id',
'label' => __( 'GA4 Property ID', 'prautoblogger' ),
'type' => 'text',
'section' => 'prautoblogger_analytics',
'description' => __( 'Format: properties/XXXXXXXXX. Leave blank to skip GA4.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_ga4_credentials_json',
'label' => __( 'Service Account JSON', 'prautoblogger' ),
'type' => 'password',
'section' => 'prautoblogger_analytics',
'description' => __( 'Paste the full JSON key file for a service account with Analytics read access.', 'prautoblogger' ),
],
// ── Images ─────────────────────────────────────────────────
[
'id' => 'prautoblogger_image_enabled',
'label' => __( 'Enable Image Generation', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '0',
'description' => __( 'When enabled, generate two images (A/B) for each published article. Requires Cloudflare credentials.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_cloudflare_ai_token',
'label' => __( 'Cloudflare API Token', 'prautoblogger' ),
'type' => 'password',
'section' => 'prautoblogger_images',
'description' => __( 'Workers AI token with Read + Edit scope. Create at dash.cloudflare.com → AI → API Tokens.', 'prautoblogger' ),
'icon' => '🔑',
],
[
'id' => 'prautoblogger_cloudflare_account_id',
'label' => __( 'Cloudflare Account ID', 'prautoblogger' ),
'type' => 'text',
'section' => 'prautoblogger_images',
'default' => '',
'description' => __( 'The Account ID shown on your Cloudflare dashboard sidebar. Used to build the Workers AI URL.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_model',
'label' => __( 'Image Model', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_images',
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL,
'options' => [
'flux-1-schnell' => __( 'FLUX.1 [schnell] — fast + cheap (default)', 'prautoblogger' ),
'flux-1-dev' => __( 'FLUX.1 [dev] — higher quality, ~4× cost', 'prautoblogger' ),
],
'description' => __( 'Schnell is the normal choice. Only switch to [dev] when a specific post needs higher quality.', 'prautoblogger' ),
'badge' => __( 'Low cost', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_style_suffix',
'label' => __( 'Style Suffix', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_images',
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX,
'description' => __( 'Appended to every image prompt. Controls the visual look across all placements. Changing this mid-run will cause visible style drift — prefer changing during quiet periods.', 'prautoblogger' ),
],
];
}
}

View File

@@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
/**
* Renders the interactive model-picker field for OpenRouter models.
*
* What: a button showing the current model's name + pricing. Clicking it
* opens a JS-powered popup (model-picker.js) with a searchable list.
* Who calls it: PRAutoBlogger_Admin_Page::render_field() delegates here
* for fields with type 'model_select'.
* Dependencies: PRAutoBlogger_Model_Registry_Interface (cached registry lookup).
*
* @see admin/class-admin-page.php — Calls render() from the main field switch.
* @see assets/js/model-picker.js — JS popup that fetches + displays models.
* @see services/interface-model-registry.php — Registry interface for find_model().
*/
class PRAutoBlogger_OpenRouter_Model_Field {
/**
* Render the model picker trigger button and hidden input.
*
* @param string $id Field HTML id/name attribute.
* @param string $value Current model id (e.g. 'google/gemini-2.5-flash-lite').
* @param array<string, mixed> $args Field definition from settings fields.
*/
public static function render( string $id, string $value, array $args ): void {
$capability = $args['capability'] ?? 'text→text';
$display_name = $value ?: __( '— Select a model —', 'prautoblogger' );
$display_price = '';
if ( 'image_generation' === $capability ) {
// Resolve from the static image model list defined in settings.
$image_models = PRAutoBlogger_Settings_Fields_Extended::get_image_models();
foreach ( $image_models as $im ) {
if ( ( $im['id'] ?? '' ) === $value ) {
$display_name = $im['name'] ?? $value;
$display_price = '$' . number_format( (float) ( $im['cost_per_image'] ?? 0 ), 4 ) . '/image';
break;
}
}
} else {
// Resolve from the OpenRouter model registry cache.
$registry = prautoblogger()->get_executor()->get_model_registry();
$model = $registry->find_model( $value );
if ( null !== $model ) {
$display_name = $model['name'] ?? $value;
$in = (float) ( $model['input_price_per_m'] ?? 0 );
$out = (float) ( $model['output_price_per_m'] ?? 0 );
$display_price = self::format_price( $in ) . ' / ' . self::format_price( $out );
}
}
// Hidden input carries the actual value for the form submission.
printf(
'<input type="hidden" id="%s" name="%s" value="%s" />',
esc_attr( $id ),
esc_attr( $id ),
esc_attr( $value )
);
// Trigger button — opens the model picker popup via JS.
printf(
'<button type="button" class="ab-mp-trigger" data-field-id="%s" data-capability="%s">'
. '<span class="ab-mp-display-name">%s</span>'
. '<span class="ab-mp-display-price">%s</span>'
. '<span class="ab-mp-display-arrow">&#9662;</span>'
. '</button>',
esc_attr( $id ),
esc_attr( $capability ),
esc_html( $display_name ),
esc_html( $display_price )
);
}
/**
* Format a price-per-million-tokens value for display.
*
* @param float $price Price per 1M tokens.
* @return string Formatted price string like "$0.25" or "Free".
*/
private static function format_price( float $price ): string {
if ( $price <= 0 ) {
return __( 'Free', 'prautoblogger' );
}
if ( $price < 0.01 ) {
return '$' . number_format( $price, 4 );
}
return '$' . number_format( $price, 2 );
}
}

View File

@@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
/**
* Renders the Reddit source status indicator in the admin settings.
*
* What: shows RSS (primary) and .json (fallback) availability with
* last-collection timestamp.
* Who calls it: PRAutoBlogger_Admin_Page::render_field() delegates here
* for fields with type 'source_status'.
* Dependencies: None — reads wp_options only.
*
* @see admin/class-admin-page.php — Calls render() from the main field switch.
*/
class PRAutoBlogger_Source_Status_Field {
/**
* Render the Reddit source status indicator.
*
* Side effects: reads wp_options for last collection time.
*/
public static function render(): void {
$last_collection = get_option( 'prautoblogger_last_collection_time', '' );
$last_source_used = get_option( 'prautoblogger_last_source_used', '' );
echo '<div class="ab-source-status">';
// Reddit RSS — primary source.
printf(
'<div class="ab-source-row">'
. '<span class="ab-source-dot ab-status-ok"></span>'
. '<strong>%s</strong> <span class="ab-source-badge">%s</span>'
. '<span class="ab-source-label">%s</span>'
. '</div>',
esc_html__( 'Reddit RSS', 'prautoblogger' ),
esc_html__( 'Primary', 'prautoblogger' ),
esc_html__( 'Reliable from all IPs', 'prautoblogger' )
);
// Reddit .json — fallback source.
printf(
'<div class="ab-source-row">'
. '<span class="ab-source-dot ab-status-ok"></span>'
. '<strong>%s</strong> <span class="ab-source-badge ab-badge-secondary">%s</span>'
. '<span class="ab-source-label">%s</span>'
. '</div>',
esc_html__( 'Reddit .json', 'prautoblogger' ),
esc_html__( 'Fallback + Comments', 'prautoblogger' ),
esc_html__( 'May be IP-blocked on some hosts', 'prautoblogger' )
);
// Last collection info.
if ( '' !== $last_collection ) {
$time_ago = human_time_diff( (int) $last_collection, time() );
printf(
'<div class="ab-source-meta">%s <strong>%s</strong> %s</div>',
esc_html__( 'Last collection:', 'prautoblogger' ),
/* translators: %s is a human-readable time difference like "2 hours" */
esc_html( sprintf( __( '%s ago', 'prautoblogger' ), $time_ago ) ),
'' !== $last_source_used ? esc_html( sprintf( __( 'via %s', 'prautoblogger' ), $last_source_used ) ) : ''
);
}
echo '</div>';
}
}

View File

@@ -28,15 +28,121 @@ class PRAutoBlogger_Activator {
self::create_tables();
self::set_default_options();
self::schedule_cron();
self::backpopulate_run_id_meta_v081();
// Store the DB version so we can run migrations on future updates.
update_option( 'prautoblogger_db_version', PRAUTOBLOGGER_DB_VERSION, false );
}
/** Delegate to `PRAutoBlogger_Schema_Installer::install()` — see that class. */
/**
* Create all custom database tables using dbDelta.
*
* Side effects: creates/updates four database tables.
*
* @return void
*/
private static function create_tables(): void {
PRAutoBlogger_Schema_Installer::install();
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$prefix = $wpdb->prefix . 'prautoblogger_';
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 );
}
/**
@@ -63,12 +169,8 @@ class PRAutoBlogger_Activator {
'prautoblogger_max_word_count' => 2000,
'prautoblogger_topic_exclusions' => '[]',
'prautoblogger_enabled_sources' => '["reddit"]',
'prautoblogger_article_font_family' => 'default',
'prautoblogger_article_font_size' => 0,
'prautoblogger_table_borders' => '1',
'prautoblogger_schedule_time' => '03:00',
'prautoblogger_log_level' => 'info',
'prautoblogger_image_nsfw_retry' => '1',
];
foreach ( $defaults as $key => $value ) {
@@ -90,8 +192,14 @@ class PRAutoBlogger_Activator {
*/
private static function schedule_cron(): void {
if ( ! wp_next_scheduled( 'prautoblogger_daily_generation' ) ) {
$timestamp = self::next_daily_generation_timestamp();
if ( $timestamp > 0 ) {
// Schedule for the configured time tomorrow.
$time_str = get_option( 'prautoblogger_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', 'prautoblogger_daily_generation' );
}
}
@@ -114,156 +222,5 @@ class PRAutoBlogger_Activator {
if ( ! wp_next_scheduled( 'prautoblogger_collect_metrics' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'prautoblogger_six_hours', 'prautoblogger_collect_metrics' );
}
// v0.8.1: daily reaper for orphan `llm_research` rows. Pinned to
// 03:15 so it runs 15 min after the primary generation cron, giving
// any live pipeline time to finish and call amortize_research_costs
// cleanly before the reaper scans.
if ( ! wp_next_scheduled( 'prautoblogger_reap_orphan_research_rows' ) ) {
$tomorrow = strtotime( 'tomorrow 03:15' );
if ( false !== $tomorrow ) {
wp_schedule_event( $tomorrow, 'daily', 'prautoblogger_reap_orphan_research_rows' );
}
}
}
/**
* Compute the next daily-generation timestamp in the site's configured
* timezone. Fixes the v<0.8.2 bug where `strtotime("tomorrow HH:MM")`
* evaluated in UTC (WP core sets `date_default_timezone_set('UTC')`),
* firing the cron N hours off from user intent where N is the site's
* UTC offset.
*
* @return int Unix timestamp, or 0 on any failure.
*/
public static function next_daily_generation_timestamp(): int {
$time_str = (string) get_option( 'prautoblogger_schedule_time', '03:00' );
$parts = explode( ':', $time_str );
$hour = isset( $parts[0] ) ? absint( $parts[0] ) : 3;
$minute = isset( $parts[1] ) ? absint( $parts[1] ) : 0;
try {
$site_tz = function_exists( 'wp_timezone' )
? wp_timezone()
: new \DateTimeZone( 'UTC' );
$local = ( new \DateTimeImmutable( 'tomorrow', $site_tz ) )
->setTime( $hour, $minute, 0 );
return (int) $local->getTimestamp();
} catch ( \Exception $e ) {
// Defensive: never leave the site without any schedule because
// of a bad tz string. Fall back to the pre-fix behaviour.
$timestamp = strtotime( "tomorrow {$hour}:{$minute}" );
return false === $timestamp ? 0 : (int) $timestamp;
}
}
/**
* v0.8.2 one-shot migration — reschedule `prautoblogger_daily_generation`
* in the site's timezone.
*
* Existing installs had their daily cron scheduled with the v<0.8.2
* UTC-interpretation bug. This clears the stale event and reschedules it
* using the timezone-aware helper, exactly once per site (gated by
* `prautoblogger_migrated_schedule_tz_v082`). Logs an INFO line with the
* before/after next-run times so the outcome is auditable.
*
* @return void
*/
public static function reschedule_daily_in_site_timezone_v082(): void {
if ( get_option( 'prautoblogger_migrated_schedule_tz_v082' ) ) {
return;
}
$old_timestamp = wp_next_scheduled( 'prautoblogger_daily_generation' );
wp_clear_scheduled_hook( 'prautoblogger_daily_generation' );
$new_timestamp = self::next_daily_generation_timestamp();
if ( $new_timestamp > 0 ) {
wp_schedule_event( $new_timestamp, 'daily', 'prautoblogger_daily_generation' );
}
if ( class_exists( 'PRAutoBlogger_Logger', false ) ) {
PRAutoBlogger_Logger::instance()->info(
sprintf(
'Rescheduled daily generation in site timezone (was UTC). Old next_run: %s, new next_run: %s.',
false !== $old_timestamp ? gmdate( 'Y-m-d H:i:s \\U\\T\\C', (int) $old_timestamp ) : 'unscheduled',
$new_timestamp > 0 ? gmdate( 'Y-m-d H:i:s \\U\\T\\C', $new_timestamp ) : 'unscheduled'
),
'activator'
);
}
update_option( 'prautoblogger_migrated_schedule_tz_v082', '1' );
}
/**
* One-shot v0.8.1 migration — back-populate `_prautoblogger_run_id`
* post_meta for existing posts from their gen-log rows.
*
* Lets the orphan-research-row reaper (v0.8.1+) attribute historic
* orphan rows to sibling posts via post_meta lookup, without having
* to walk the generation_log table. Gated by
* `prautoblogger_migrated_run_id_meta_v081` so it runs exactly once
* per site.
*
* Side effects: adds post_meta rows for existing PRAutoBlogger posts
* that don't already have `_prautoblogger_run_id`.
*
* @return void
*/
private static function backpopulate_run_id_meta_v081(): void {
if ( get_option( 'prautoblogger_migrated_run_id_meta_v081' ) ) {
return;
}
global $wpdb;
$gen_log = $wpdb->prefix . 'prautoblogger_generation_log';
// For each (post_id, run_id) pair present in the gen-log, write
// the run_id meta if it's not already set. Uses the first run_id
// seen per post — in practice each post is linked to exactly one run.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$pairs = $wpdb->get_results(
"SELECT post_id, MIN(run_id) AS run_id FROM {$gen_log}
WHERE post_id IS NOT NULL AND run_id IS NOT NULL AND run_id != ''
GROUP BY post_id"
);
if ( is_array( $pairs ) ) {
foreach ( $pairs as $row ) {
$post_id = (int) ( $row->post_id ?? 0 );
$run_id = (string) ( $row->run_id ?? '' );
if ( $post_id > 0 && '' !== $run_id && ! metadata_exists( 'post', $post_id, '_prautoblogger_run_id' ) ) {
update_post_meta( $post_id, '_prautoblogger_run_id', $run_id );
}
}
}
update_option( 'prautoblogger_migrated_run_id_meta_v081', '1' );
}
/**
* v0.9.0 migration: flip legacy default image model to Runware schnell
* and re-derive provider. CEO decision 2026-04-21 — 65× cheaper than
* Gemini Nano Banana. Explicit user selections are preserved.
*
* Side effects: may update `prautoblogger_image_model`,
* `prautoblogger_image_provider`, and sets the
* `prautoblogger_migrated_default_image_v090` flag.
*/
public static function migrate_default_image_v090(): void {
if ( get_option( 'prautoblogger_migrated_default_image_v090' ) ) {
return;
}
$curr = (string) get_option( 'prautoblogger_image_model', '' );
if ( in_array( $curr, [ '', 'google/gemini-2.5-flash-image' ], true ) ) {
update_option( 'prautoblogger_image_model', 'runware:100@1' );
}
$final = (string) get_option( 'prautoblogger_image_model', 'runware:100@1' );
$prov = PRAutoBlogger_Image_Model_Registry::provider_for( $final );
if ( '' !== $prov ) {
update_option( 'prautoblogger_image_provider', $prov );
}
update_option( 'prautoblogger_migrated_default_image_v090', '1' );
}
}

View File

@@ -1,145 +0,0 @@
<?php
declare(strict_types=1);
/**
* AJAX endpoint handlers for PRAutoBlogger admin actions.
*
* What: Handles image generation, model registry, and connection test AJAX requests.
* Who calls it: PRAutoBlogger registers wp_ajax hooks that delegate to methods here.
* Dependencies: PRAutoBlogger_Image_Pipeline, PRAutoBlogger_Source_Collector,
* PRAutoBlogger_OpenRouter_Provider, PRAutoBlogger_Reddit_Provider,
* PRAutoBlogger_OpenRouter_Model_Registry, PRAutoBlogger_Logger.
*
* @see class-prautoblogger.php — Hook registration that wires these handlers.
* @see class-executor.php — Generation-related AJAX lives there (generate_now, status).
*/
class PRAutoBlogger_Ajax_Handlers {
/** @var PRAutoBlogger_OpenRouter_Model_Registry Model registry instance (shared with Executor). */
private PRAutoBlogger_OpenRouter_Model_Registry $model_registry;
/**
* @param PRAutoBlogger_OpenRouter_Model_Registry $model_registry Shared model registry.
*/
public function __construct( PRAutoBlogger_OpenRouter_Model_Registry $model_registry ) {
$this->model_registry = $model_registry;
}
/**
* AJAX handler: generate images for existing posts that lack them.
*
* Accepts a `post_id` parameter. Generates Image A (article-driven) and
* sets it as the featured image. Useful for retroactively adding images
* to posts published before image generation was enabled/fixed.
*
* Side effects: image provider API call, media library write, post meta update.
*/
public function on_ajax_generate_image(): void {
check_ajax_referer( 'prautoblogger_generate_image', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
return;
}
// Image generation can take 20-30s (Runware FLUX + OpenRouter image models).
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( 120 );
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
if ( 0 === $post_id ) {
wp_send_json_error( [ 'message' => __( 'Missing post_id parameter.', 'prautoblogger' ) ] );
return;
}
$post = get_post( $post_id );
if ( ! $post ) {
wp_send_json_error( [ 'message' => sprintf( __( 'Post %d not found.', 'prautoblogger' ), $post_id ) ] );
return;
}
try {
$pipeline = new PRAutoBlogger_Image_Pipeline();
$article_data = [
'post_title' => $post->post_title,
'post_content' => $post->post_content,
];
// Retrieve original source IDs from post meta so Image B can generate
// a source-driven prompt even on retroactive regeneration.
$source_ids_json = get_post_meta( $post_id, '_prautoblogger_source_ids', true );
$source_ids = is_string( $source_ids_json ) ? json_decode( $source_ids_json, true ) : [];
$source_data = ( new PRAutoBlogger_Source_Collector() )->get_source_data_for_image(
is_array( $source_ids ) ? array_map( 'absint', $source_ids ) : []
);
// The pipeline sets featured image and Image B meta internally.
$result = $pipeline->generate_and_attach_images( $post_id, $article_data, $source_data );
wp_send_json_success( $result );
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Retroactive image gen %s for post %d: %s', get_class( $e ), $post_id, $e->getMessage() ),
'image_pipeline'
);
wp_send_json_error( [ 'message' => $e->getMessage() ] );
}
}
/**
* AJAX handler: return the model registry for the admin model picker.
*
* Returns the full cached model list. If the registry is empty, triggers
* a refresh first. Called by the model-picker.js popup.
*
* Side effects: may trigger an OpenRouter API call if registry is empty.
*/
public function on_ajax_get_models(): void {
check_ajax_referer( 'prautoblogger_get_models', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
return;
}
$models = $this->model_registry->get_models();
// Auto-refresh if the registry has never been populated.
if ( empty( $models ) ) {
$this->model_registry->refresh( true );
$models = $this->model_registry->get_models();
}
wp_send_json_success( $models );
}
/**
* AJAX handler: test API connections (OpenRouter, Reddit).
*
* Accepts a `service` parameter: 'openrouter', 'reddit', or 'all'.
*
* Side effects: API calls to OpenRouter and/or Reddit to validate credentials.
*/
public function on_ajax_test_connection(): void {
check_ajax_referer( 'prautoblogger_test_connection', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
return;
}
$service = isset( $_POST['service'] ) ? sanitize_text_field( wp_unslash( $_POST['service'] ) ) : '';
$results = [];
if ( 'openrouter' === $service || 'all' === $service ) {
$results['openrouter'] = ( new PRAutoBlogger_OpenRouter_Provider() )->validate_credentials_detailed();
}
if ( 'reddit' === $service || 'all' === $service ) {
$reddit = new PRAutoBlogger_Reddit_Provider();
$is_ok = $reddit->validate_credentials();
$results['reddit'] = $is_ok
? [ 'status' => 'ok', 'message' => __( 'Reddit source available (RSS primary, .json fallback).', 'prautoblogger' ) ]
: [ 'status' => 'error', 'message' => __( 'Reddit sources unreachable (both RSS and .json failed).', 'prautoblogger' ) ];
}
wp_send_json_success( $results );
}
}

View File

@@ -22,7 +22,6 @@ class PRAutoBlogger_Autoloader {
private static array $directories = [
'', // includes/ root
'admin/',
'admin/fields/',
'core/',
'providers/',
'models/',

View File

@@ -36,7 +36,6 @@ class PRAutoBlogger_Deactivator {
$hooks = [
'prautoblogger_daily_generation',
'prautoblogger_collect_metrics',
'prautoblogger_reap_orphan_research_rows',
];
foreach ( $hooks as $hook ) {

View File

@@ -1,298 +0,0 @@
<?php
declare(strict_types=1);
/**
* Cron/AJAX handlers for content generation, metrics collection, model registry.
*
* Triggered by: PRAutoBlogger hook registration.
* Dependencies: Pipeline_Runner, Metrics_Collector, Generation_Lock, Logger.
*
* @see class-prautoblogger.php — Hook wiring.
* @see core/class-pipeline-runner.php — Actual generation pipeline.
* @see class-generation-lock.php — DB mutex.
*/
class PRAutoBlogger_Executor {
/** @var PRAutoBlogger_OpenRouter_Model_Registry|null Lazy-loaded singleton. */
private ?PRAutoBlogger_OpenRouter_Model_Registry $model_registry = null;
/** Transient key for background generation status. */
private const STATUS_TRANSIENT = 'prautoblogger_generation_status';
/** How long to keep the result available for polling (seconds). */
private const STATUS_TTL = 600;
/**
* Handle the daily generation cron event.
*
* Uses an atomic DB mutex. If the pipeline queues additional articles,
* they fire as chained cron events and the lock is released by the
* last article job — not here.
*
* Side effects: API calls, database writes, WordPress post creation.
*/
public function on_daily_generation(): void {
do_action( 'prautoblogger_refresh_model_registry', false );
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
PRAutoBlogger_Logger::instance()->info( 'Daily generation skipped: already running (lock held).', 'scheduler' );
return;
}
try {
( new PRAutoBlogger_Pipeline_Runner() )->run();
// Release lock only if no articles were queued for chained processing.
if ( ! get_option( 'prautoblogger_article_queue' ) ) {
PRAutoBlogger_Generation_Lock::release();
}
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Daily generation %s: %s', get_class( $e ), $e->getMessage() ),
'scheduler'
);
PRAutoBlogger_Generation_Lock::release();
}
}
/**
* Handle the metrics collection cron event.
* Side effects: API calls to GA4, database writes to ab_content_scores.
*/
public function on_collect_metrics(): void {
try {
( new PRAutoBlogger_Metrics_Collector() )->collect_all();
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Metrics collection %s: %s', get_class( $e ), $e->getMessage() ),
'metrics'
);
}
}
/**
* AJAX handler: kick off manual generation as a background cron job.
*
* Returns immediately so the browser never hits Hostinger's 120-second
* connection timeout. The frontend polls on_ajax_generation_status()
* every few seconds to get progress and final results.
*
* Side effects: schedules a WP-Cron event, writes a status transient.
*/
public function on_ajax_generate_now(): void {
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
return;
}
$force = isset( $_POST['force'] ) && '1' === $_POST['force'];
if ( $force ) {
PRAutoBlogger_Generation_Lock::release();
delete_transient( self::STATUS_TRANSIENT );
}
// Check if a run is already in progress.
$current = get_transient( self::STATUS_TRANSIENT );
if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) {
wp_send_json_success( [
'background' => true,
'message' => __( 'Generation already in progress.', 'prautoblogger' ),
] );
return;
}
// Write initial "running" status for the frontend to poll.
set_transient( self::STATUS_TRANSIENT, [
'status' => 'running',
'stage' => __( 'Starting generation...', 'prautoblogger' ),
'started' => time(),
'last_updated' => time(),
], self::STATUS_TTL );
// Schedule immediate one-shot cron event. WordPress will fire this
// on the next page load (or via wp-cron.php) in a separate PHP process
// that is not subject to the 120-second connection timeout.
if ( ! wp_next_scheduled( 'prautoblogger_manual_generation' ) ) {
wp_schedule_single_event( time(), 'prautoblogger_manual_generation' );
}
// Spawn the cron immediately via a non-blocking loopback request
// so we don't depend on the next visitor to trigger it.
spawn_cron();
// Direct non-blocking loopback to wp-cron.php — ensures the event
// fires even if DISABLE_WP_CRON is true or spawn_cron() no-ops.
wp_remote_post(
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
[
'timeout' => 0.01,
'blocking' => false,
'sslverify' => false,
]
);
wp_send_json_success( [
'background' => true,
'message' => __( 'Generation started in background. Polling for status...', 'prautoblogger' ),
] );
}
/**
* Cron handler: runs pipeline orchestration + first article.
*
* If the pipeline queues additional articles (2..N), they will fire as
* chained cron events — each in its own PHP process. The lock is released
* by the pipeline when the last article completes, not here.
*/
public function on_manual_generation(): void {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@ignore_user_abort( true );
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( 300 );
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
set_transient( self::STATUS_TRANSIENT, [
'status' => 'error',
'message' => __( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' ),
], self::STATUS_TTL );
return;
}
try {
$this->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) );
$runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true );
$results = $runner->run();
// If no articles were queued (target=1 or 0 ideas), finalize here.
// When articles ARE queued, the pipeline handles status + lock release.
if ( ! get_option( 'prautoblogger_article_queue' ) ) {
set_transient( self::STATUS_TRANSIENT, [
'status' => 'complete',
'generated' => $results['generated'],
'published' => $results['published'],
'rejected' => $results['rejected'],
'cost' => $results['cost'],
], self::STATUS_TTL );
PRAutoBlogger_Generation_Lock::release();
}
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Manual generation %s: %s', get_class( $e ), $e->getMessage() ),
'pipeline'
);
set_transient( self::STATUS_TRANSIENT, [
'status' => 'error',
'message' => $e->getMessage(),
], self::STATUS_TTL );
PRAutoBlogger_Generation_Lock::release();
}
}
/** Cron handler: process next queued article. Chained by Pipeline_Runner. */
public function on_process_article_queue(): void {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@ignore_user_abort( true );
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( 300 );
try {
( new PRAutoBlogger_Pipeline_Runner() )->process_next_queued_article();
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Queued article generation %s: %s', get_class( $e ), $e->getMessage() ),
'pipeline'
);
// Clean up on catastrophic failure.
delete_option( 'prautoblogger_article_queue' );
PRAutoBlogger_Generation_Lock::release();
set_transient( self::STATUS_TRANSIENT, [
'status' => 'error',
'message' => $e->getMessage(),
], self::STATUS_TTL );
}
}
/** AJAX: return generation status for frontend polling. Recovers orphaned runs. */
public function on_ajax_generation_status(): void {
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
return;
}
$status = get_transient( self::STATUS_TRANSIENT );
if ( ! is_array( $status ) ) {
wp_send_json_success( [ 'status' => 'idle' ] );
return;
}
if ( 'running' === $status['status'] ) {
$elapsed = time() - ( $status['started'] ?? 0 );
// Absolute timeout — give up after 10 minutes.
if ( $elapsed > self::STATUS_TTL ) {
$this->abort_orphaned_run( __( 'Generation timed out. Check the Activity Log.', 'prautoblogger' ) );
return;
}
// After 90s, check if a queued job died and needs re-scheduling.
if ( $elapsed > 90 && get_option( 'prautoblogger_article_queue' ) ) {
if ( ! wp_next_scheduled( PRAutoBlogger_Pipeline_Runner::CRON_ACTION ) ) {
wp_schedule_single_event( time(), PRAutoBlogger_Pipeline_Runner::CRON_ACTION );
spawn_cron();
}
}
// Stall = 5 min since last stage update (not since start).
$last_activity = $status['last_updated'] ?? $status['started'] ?? 0;
$idle_seconds = time() - $last_activity;
if ( $idle_seconds > 300 ) {
$this->abort_orphaned_run( __( 'Generation stalled. Check Activity Log.', 'prautoblogger' ) );
return;
}
}
wp_send_json_success( $status );
}
/** Clean up an orphaned generation run and report error. */
private function abort_orphaned_run( string $message ): void {
delete_transient( self::STATUS_TRANSIENT );
delete_option( 'prautoblogger_article_queue' );
PRAutoBlogger_Generation_Lock::release();
wp_send_json_success( [ 'status' => 'error', 'message' => $message ] );
}
/**
* Helper: update the generation stage for frontend polling.
*
* @param string $stage Human-readable stage description.
*/
private function update_generation_stage( string $stage ): void {
$current = get_transient( self::STATUS_TRANSIENT );
if ( is_array( $current ) ) {
$current['stage'] = $stage;
$current['last_updated'] = time();
set_transient( self::STATUS_TRANSIENT, $current, self::STATUS_TTL );
}
}
/**
* Lazy-load the OpenRouter model registry singleton.
* Config injected here — registry class has no knowledge of PRAUTOBLOGGER_* constants.
*
* @return PRAutoBlogger_OpenRouter_Model_Registry
*/
public function get_model_registry(): PRAutoBlogger_OpenRouter_Model_Registry {
if ( null === $this->model_registry ) {
$this->model_registry = new PRAutoBlogger_OpenRouter_Model_Registry(
'prautoblogger_openrouter_model_registry',
'prautoblogger_openrouter_model_registry_cache'
);
}
return $this->model_registry;
}
}

View File

@@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
/**
* Database-level atomic mutex for content generation.
*
* What: Provides acquire/release/check semantics for a single-writer generation lock.
* Who calls it: PRAutoBlogger_Executor (daily and manual generation), status polling fallback.
* Dependencies: WordPress $wpdb for direct SQL.
*
* Uses INSERT IGNORE against the wp_options UNIQUE index on option_name to
* guarantee only one PHP process can hold the lock at a time. Expired locks
* (>1 hour) are cleaned up automatically on acquire to prevent permanent deadlock.
*
* @see class-executor.php — Acquires/releases the lock around pipeline runs.
* @see class-ajax-handlers.php — Releases stale locks in the status fallback path.
*/
class PRAutoBlogger_Generation_Lock {
/** @var string Option name used as the lock key in wp_options. */
private const LOCK_NAME = 'prautoblogger_generation_lock';
/**
* Acquire the generation mutex.
*
* Cleans up expired locks (>1 hour) first, then attempts an atomic INSERT
* IGNORE. The UNIQUE constraint on option_name ensures only one caller wins.
*
* @return bool True if lock acquired, false if already held.
*/
public static function acquire(): bool {
global $wpdb;
// Clean up expired locks to prevent 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",
self::LOCK_NAME,
time() - HOUR_IN_SECONDS
)
);
// Atomic insert — UNIQUE constraint 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)",
self::LOCK_NAME,
(string) time(),
'no'
)
);
return $result > 0;
}
/**
* Check if the generation lock is currently held.
*
* @return bool True if the lock exists (generation in progress or stuck).
*/
public static function is_locked(): bool {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$row = $wpdb->get_var(
$wpdb->prepare(
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
self::LOCK_NAME
)
);
return null !== $row;
}
/**
* Release the generation mutex.
*
* Safe to call even if the lock is not held (no-ops gracefully).
*
* Side effects: deletes the lock row from wp_options.
*/
public static function release(): 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",
self::LOCK_NAME
)
);
}
}

View File

@@ -1,99 +0,0 @@
<?php
declare(strict_types=1);
/**
* One-shot v0.10.0 migration — remove Cloudflare Workers AI as image provider.
*
* Cloudflare Workers AI was removed as an image provider in v0.10.0.
* Runware FLUX.1 schnell is 65× cheaper at equivalent quality, so any user
* still on a `cloudflare` provider must be migrated off — otherwise the
* image pipeline throws "no provider for id cloudflare" at the first
* generation run after upgrade.
*
* Split out of class-activator.php so that file stays under the 300-line
* cap. The activator's maybe_run_db_migrations() delegates here.
*
* Triggered by: PRAutoBlogger::maybe_run_db_migrations() once per admin_init
* until the `prautoblogger_migrated_remove_cloudflare_v0100`
* flag is set.
* Dependencies: PRAutoBlogger_Image_Model_Registry::provider_for() for
* current-model provider derivation.
*
* @see class-activator.php — Parallel v0.9.0 migration.
* @see class-prautoblogger.php — Calls run() during admin_init.
* @see class-image-model-registry.php — Provider derivation source.
* @see convo/prautoblogger/threads/2026-04-cloudflare-workers-ai-removal/
*/
class PRAutoBlogger_Migrate_Remove_Cloudflare_V0100 {
/**
* Migration flag option key. Presence = already migrated.
*/
private const FLAG = 'prautoblogger_migrated_remove_cloudflare_v0100';
/**
* Run the migration. Idempotent — second + later calls are no-ops.
*
* Side effects: may update `prautoblogger_image_model` +
* `prautoblogger_image_provider`; always deletes
* `prautoblogger_cloudflare_ai_token`,
* `prautoblogger_cloudflare_account_id`,
* `prautoblogger_cf_image_via_gateway`; sets the
* migration flag option.
*
* Explicitly does NOT touch `prautoblogger_ai_gateway_base_url` —
* that option is shared with the OpenRouter AI Gateway route and
* must stay intact.
*
* @return void
*/
public static function run(): void {
if ( get_option( self::FLAG ) ) {
return;
}
if ( self::is_user_on_cloudflare() ) {
// Closest equivalent — FLUX.1 schnell, Runware-hosted, ~$0.0006/image.
update_option( 'prautoblogger_image_model', 'runware:100@1' );
update_option( 'prautoblogger_image_provider', 'runware' );
}
// Clean up orphaned CF Workers AI credentials + the dead gateway toggle.
delete_option( 'prautoblogger_cloudflare_ai_token' );
delete_option( 'prautoblogger_cloudflare_account_id' );
delete_option( 'prautoblogger_cf_image_via_gateway' );
update_option( self::FLAG, '1' );
}
/**
* Detect whether the site is currently configured to use Cloudflare
* Workers AI for image generation.
*
* Because the v0.10.0 image-model registry no longer contains
* Cloudflare entries, `provider_for()` returns '' for legacy
* `flux-1-schnell` slugs. We therefore look at three signals and
* treat any of them as "on Cloudflare":
* 1. Current registry-derived provider === 'cloudflare'.
* 2. The legacy `prautoblogger_image_provider` option is 'cloudflare'.
* 3. The legacy CF-specific slug `flux-1-schnell` is still persisted
* as the image model (pre-registry-rename installs).
*
* @return bool
*/
private static function is_user_on_cloudflare(): bool {
$curr = (string) get_option( 'prautoblogger_image_model', '' );
$curr_provider = PRAutoBlogger_Image_Model_Registry::provider_for( $curr );
if ( 'cloudflare' === $curr_provider ) {
return true;
}
$legacy_provider = (string) get_option( 'prautoblogger_image_provider', '' );
if ( 'cloudflare' === $legacy_provider ) {
return true;
}
return 'flux-1-schnell' === $curr;
}
}

View File

@@ -9,160 +9,144 @@ declare(strict_types=1);
* business logic, not hook registration.
*
* Triggered by: prautoblogger() singleton in prautoblogger.php, called on `plugins_loaded`.
* Dependencies: All other plugin classes (loaded via autoloader), PRAutoBlogger_Executor.
* Dependencies: All other plugin classes (loaded via autoloader), PRAutoBlogger_Pipeline_Runner.
*
* @see prautoblogger.php — Plugin bootstrap that instantiates this class.
* @see class-executor.php — Cron/AJAX handlers for generation, model registry.
* @see class-ajax-handlers.php — Non-generation AJAX endpoints (images, models, test).
* @see class-generation-lock.php — DB mutex for single-writer generation.
* @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.
* @see prautoblogger.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 PRAutoBlogger {
private bool $initialized = false;
/** @var PRAutoBlogger_Executor Handles generation cron/AJAX, model registry. */
private PRAutoBlogger_Executor $executor;
/** @var PRAutoBlogger_Ajax_Handlers Handles non-generation AJAX (images, models, test). */
private PRAutoBlogger_Ajax_Handlers $ajax_handlers;
/** @var PRAutoBlogger_OpenRouter_Model_Registry|null Lazy-loaded singleton. */
private ?PRAutoBlogger_OpenRouter_Model_Registry $model_registry = null;
/**
* Register all hooks and initialize the plugin.
* Called once on `plugins_loaded`. Idempotent.
*
* 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;
$this->executor = new PRAutoBlogger_Executor();
$this->ajax_handlers = new PRAutoBlogger_Ajax_Handlers( $this->executor->get_model_registry() );
$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();
// Frontend hooks — shortcode and REST API for public-facing widget.
$this->register_frontend_hooks();
// AJAX hooks — always registered so admin AJAX works.
$this->register_ajax_hooks();
/** Fires after PRAutoBlogger has finished registering all hooks. */
/**
* Fires after PRAutoBlogger has finished registering all hooks.
*
* Useful for add-ons that need to hook into PRAutoBlogger's lifecycle.
*/
do_action( 'prautoblogger_loaded' );
}
/** Register admin-only hooks (settings, notices, metabox, dashboard widget). */
/**
* Register admin-only hooks (settings page, notices, metabox, dashboard widget).
*
* @return void
*/
private function register_admin_hooks(): void {
$admin_page = new PRAutoBlogger_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' ] );
add_action( 'admin_notices', [ new PRAutoBlogger_Admin_Notices(), 'on_display_notices' ] );
add_action( 'add_meta_boxes', [ new PRAutoBlogger_Post_Metabox(), 'on_register_metabox' ] );
add_action( 'admin_menu', [ new PRAutoBlogger_Metrics_Page(), 'on_register_menu' ] );
add_action( 'wp_dashboard_setup', [ new PRAutoBlogger_Dashboard_Widget(), 'on_register_widget' ] );
$notices = new PRAutoBlogger_Admin_Notices();
add_action( 'admin_notices', [ $notices, 'on_display_notices' ] );
$metabox = new PRAutoBlogger_Post_Metabox();
add_action( 'add_meta_boxes', [ $metabox, 'on_register_metabox' ] );
$metrics_page = new PRAutoBlogger_Metrics_Page();
add_action( 'admin_menu', [ $metrics_page, 'on_register_menu' ] );
$dashboard_widget = new PRAutoBlogger_Dashboard_Widget();
add_action( 'wp_dashboard_setup', [ $dashboard_widget, 'on_register_widget' ] );
$review_queue = new PRAutoBlogger_Review_Queue();
add_action( 'admin_menu', [ $review_queue, 'on_register_menu' ] );
$ideas_browser = new PRAutoBlogger_Ideas_Browser();
add_action( 'admin_menu', [ $ideas_browser, 'on_register_menu' ] );
add_action( 'admin_menu', [ new PRAutoBlogger_Log_Viewer(), 'on_register_menu' ] );
$log_viewer = new PRAutoBlogger_Log_Viewer();
add_action( 'admin_menu', [ $log_viewer, 'on_register_menu' ] );
( new PRAutoBlogger_Post_List_Columns() )->register();
// Block false update notifications — our slug collides with another plugin.
// Block false update notifications from wordpress.org — our plugin name
// collides with a different "PRAutoBlogger" in the plugin directory.
add_filter( 'site_transient_update_plugins', [ $this, 'filter_block_false_updates' ] );
}
/** Register frontend hooks (shortcode, REST, typography). */
/**
* Register frontend hooks (shortcode and REST API for posts widget).
*
* The shortcode renders a mount-point div; the React component hydrates it
* on the client side by fetching posts from the REST endpoint.
*
* @return void
*/
private function register_frontend_hooks(): void {
$posts_widget = new PRAutoBlogger_Posts_Widget();
add_action( 'init', [ $posts_widget, 'on_register_shortcode' ] );
add_action( 'rest_api_init', [ $posts_widget, 'on_register_rest_route' ] );
$typography = new PRAutoBlogger_Article_Typography();
add_action( 'wp_head', [ $typography, 'on_wp_head' ] );
add_action( 'wp_enqueue_scripts', [ $typography, 'on_enqueue_fonts' ] );
add_filter( 'the_content', [ $typography, 'on_wrap_tables' ], 99 );
}
/** Register cron-triggered hooks for scheduled generation and metrics. */
private function register_cron_hooks(): void {
add_action( 'prautoblogger_daily_generation', [ $this->executor, 'on_daily_generation' ] );
add_action( 'prautoblogger_collect_metrics', [ $this->executor, 'on_collect_metrics' ] );
add_action( 'prautoblogger_daily_generation', [ $this, 'on_daily_generation' ] );
add_action( 'prautoblogger_collect_metrics', [ $this, 'on_collect_metrics' ] );
// Manual "Generate Now" runs as a one-shot cron event to avoid
// Hostinger's 120-second web-server connection timeout.
add_action( 'prautoblogger_manual_generation', [ $this->executor, 'on_manual_generation' ] );
// Chained article generation — each queued article fires as its own
// cron event so it gets a fresh PHP process and execution time budget.
add_action(
PRAutoBlogger_Pipeline_Runner::CRON_ACTION,
[ $this->executor, 'on_process_article_queue' ]
);
$registry = $this->executor->get_model_registry();
// Model registry refresh — fires inside on_daily_generation() before the
// generation lock, so a stuck lock doesn't freeze the registry too.
$registry = $this->get_model_registry();
add_action( 'prautoblogger_refresh_model_registry', [ $registry, 'refresh' ] );
// Single-idea generation from the Ideas browser page.
add_action( 'prautoblogger_generate_from_idea', [ 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ] );
// v0.8.1: daily reaper for orphan `llm_research` rows (when a
// pipeline dies before amortize_research_costs runs).
add_action( 'prautoblogger_reap_orphan_research_rows', [ 'PRAutoBlogger_Research_Reaper', 'on_cron' ] );
// v0.8.1: WP-CLI manual trigger for the reaper. Only registers when
// WP-CLI is present; no-op in normal HTTP requests.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
\WP_CLI::add_command( 'prautoblogger reap-research', static function (): void {
$stats = PRAutoBlogger_Research_Reaper::reap();
\WP_CLI::success( sprintf(
'Reaped %d, deleted %d (stale), skipped %d.',
(int) $stats['reaped'],
(int) $stats['deleted'],
(int) $stats['skipped']
) );
} );
}
}
/** Register AJAX handlers for admin actions. */
private function register_ajax_hooks(): void {
// Generation AJAX (start + status polling) stays on executor.
add_action( 'wp_ajax_prautoblogger_generate_now', [ $this->executor, 'on_ajax_generate_now' ] );
add_action( 'wp_ajax_prautoblogger_generation_status', [ $this->executor, 'on_ajax_generation_status' ] );
// Non-generation AJAX (images, models, test) on dedicated handler.
add_action( 'wp_ajax_prautoblogger_generate_image', [ $this->ajax_handlers, 'on_ajax_generate_image' ] );
add_action( 'wp_ajax_prautoblogger_test_connection', [ $this->ajax_handlers, 'on_ajax_test_connection' ] );
add_action( 'wp_ajax_prautoblogger_get_models', [ $this->ajax_handlers, 'on_ajax_get_models' ] );
add_action( 'wp_ajax_prautoblogger_generate_now', [ $this, 'on_ajax_generate_now' ] );
add_action( 'wp_ajax_prautoblogger_test_connection', [ $this, 'on_ajax_test_connection' ] );
$review_queue = new PRAutoBlogger_Review_Queue();
add_action( 'wp_ajax_prautoblogger_approve_post', [ $review_queue, 'on_ajax_approve_post' ] );
add_action( 'wp_ajax_prautoblogger_reject_post', [ $review_queue, 'on_ajax_reject_post' ] );
add_action( 'wp_ajax_prautoblogger_clear_logs', [ new PRAutoBlogger_Log_Viewer(), 'on_ajax_clear_logs' ] );
$ideas = new PRAutoBlogger_Ideas_Browser();
add_action( 'wp_ajax_prautoblogger_generate_from_idea', [ $ideas, 'on_ajax_generate_from_idea' ] );
add_action( 'wp_ajax_prautoblogger_idea_gen_status', [ $ideas, 'on_ajax_idea_gen_status' ] );
$log_viewer = new PRAutoBlogger_Log_Viewer();
add_action( 'wp_ajax_prautoblogger_clear_logs', [ $log_viewer, 'on_ajax_clear_logs' ] );
}
/**
* Check if database needs migration after plugin update.
* Compares stored DB version with PRAUTOBLOGGER_DB_VERSION; re-runs activation if different.
*
* Compares stored DB version with current PRAUTOBLOGGER_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( 'prautoblogger_db_version', '0' );
@@ -178,68 +162,15 @@ class PRAutoBlogger {
update_option( 'prautoblogger_migrated_gemini_flash_lite', '1' );
}
// One-time migration (v0.8.0): the admin no longer has an independent
// Image Provider dropdown; provider is derived from the image model on
// save. Auto-heal any existing site where the saved provider doesn't
// match the saved model's provider (the root cause of posts 650/657
// silently missing their featured image on 2026-04-20). Runs once.
if ( ! get_option( 'prautoblogger_migrated_image_provider_v080' ) ) {
$saved_model = (string) get_option( 'prautoblogger_image_model', '' );
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $saved_model );
if ( '' !== $provider ) {
update_option( 'prautoblogger_image_provider', $provider );
}
update_option( 'prautoblogger_migrated_image_provider_v080', '1' );
}
// One-time migration (v0.8.2): reschedule the daily-generation cron in
// the site's configured timezone. Pre-v0.8.2 activator interpreted the
// admin "Generation Time" input as UTC; after v0.8.2 it honours the
// site timezone. Clears the stale UTC-scheduled event and re-queues
// using the timezone-aware helper. See class-activator.php.
PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
// One-time migration (v3): switch to single-panel newspaper comic style.
// Replaces both the old infomercial pastiche and the short-lived premium
// photography style. Force-update unless the user has a truly custom value.
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v3' ) ) {
$known_old_prefixes = [
'Style: a screengrab from a 1995', // v1: infomercial.
'Style: premium scientific lifestyle', // v2: photography.
];
$current = get_option( 'prautoblogger_image_style_suffix', '' );
$is_old = ( '' === $current );
foreach ( $known_old_prefixes as $prefix ) {
if ( false !== strpos( $current, $prefix ) ) {
$is_old = true;
break;
}
}
if ( $is_old ) {
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
}
update_option( 'prautoblogger_migrated_style_suffix_v3', '1' );
}
// One-time migration (v4): remove caption-in-image instruction from style suffix.
// Captions are now inserted as HTML below the image, not baked into the image.
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v4' ) ) {
$current = get_option( 'prautoblogger_image_style_suffix', '' );
// Detect v3 style suffix (contains "caption text" instruction).
if ( false !== strpos( $current, 'caption text in a clean sans-serif font below the panel' ) ) {
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
}
update_option( 'prautoblogger_migrated_style_suffix_v4', '1' );
}
// v0.9.0 — Runware as default image model. v0.10.0 — remove CF Workers AI.
PRAutoBlogger_Activator::migrate_default_image_v090();
PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
// One-time migration: re-wrap existing encrypted values with "enc:" prefix.
// One-time migration: re-wrap existing encrypted values with "enc:" prefix
// to prevent the double-encryption bug permanently.
if ( ! get_option( 'prautoblogger_migrated_enc_prefix' ) ) {
$enc_options = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' ];
$enc_options = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json' ];
foreach ( $enc_options as $opt ) {
$val = get_option( $opt, '' );
// If it has a value but no "enc:" prefix, it's a legacy encrypted value.
// Clear it — user must re-enter. We can't reliably tell if it's
// single or double encrypted, so a fresh entry is safest.
if ( '' !== $val && ! PRAutoBlogger_Encryption::is_encrypted( $val ) ) {
delete_option( $opt );
}
@@ -252,7 +183,8 @@ class PRAutoBlogger {
* 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}>
*
* @return array<string, array{interval: int, display: string}> Modified schedules.
*/
public function filter_add_cron_schedules( array $schedules ): array {
$schedules['prautoblogger_six_hours'] = [
@@ -264,37 +196,209 @@ class PRAutoBlogger {
/**
* Block false update notifications from wordpress.org.
*
* Our plugin slug may collide with a different plugin in the directory.
* This filter removes any update notice that wordpress.org might push
* for our plugin basename, since we manage updates via our own CI/CD.
*
* @param object $transient The update_plugins transient data.
* @return object Modified transient.
*
* @return object Modified transient with our plugin removed from updates.
*/
public function filter_block_false_updates( $transient ) {
if ( isset( $transient->response[ PRAUTOBLOGGER_PLUGIN_BASENAME ] ) ) {
unset( $transient->response[ PRAUTOBLOGGER_PLUGIN_BASENAME ] );
$plugin_basename = PRAUTOBLOGGER_PLUGIN_BASENAME;
if ( isset( $transient->response[ $plugin_basename ] ) ) {
unset( $transient->response[ $plugin_basename ] );
}
return $transient;
}
/** Expose the executor for external access (e.g., model registry). */
public function get_executor(): PRAutoBlogger_Executor {
return $this->executor;
/**
* 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 {
// Refresh the OpenRouter model registry (idempotent, skips if <12h old).
// Fires BEFORE the generation lock so a stuck lock doesn't block the refresh.
do_action( 'prautoblogger_refresh_model_registry', false );
if ( ! $this->acquire_generation_lock() ) {
PRAutoBlogger_Logger::instance()->info( 'Daily generation skipped: already running (lock held).', 'scheduler' );
return;
}
try {
( new PRAutoBlogger_Pipeline_Runner() )->run();
} catch ( \Exception $e ) {
PRAutoBlogger_Logger::instance()->error( 'Daily generation FAILED: ' . $e->getMessage(), 'scheduler' );
} finally {
$this->release_generation_lock();
}
}
// ── Backward-compatible proxies ──
/**
* 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 PRAutoBlogger_Metrics_Collector();
$metrics->collect_all();
} catch ( \Exception $e ) {
PRAutoBlogger_Logger::instance()->error( 'Metrics collection FAILED: ' . $e->getMessage(), 'metrics' );
}
}
/** @see PRAutoBlogger_Executor::on_daily_generation() */
public function on_daily_generation(): void { $this->executor->on_daily_generation(); }
/**
* 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( 'prautoblogger_generate_now', 'nonce' );
/** @see PRAutoBlogger_Executor::on_collect_metrics() */
public function on_collect_metrics(): void { $this->executor->on_collect_metrics(); }
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
return;
}
/** @see PRAutoBlogger_Executor::on_ajax_generate_now() */
public function on_ajax_generate_now(): void { $this->executor->on_ajax_generate_now(); }
// LLM API calls can take 30-60+ seconds each. Extend PHP's execution
// limit so the pipeline isn't killed mid-generation. This may be
// blocked by Hostinger's hardcoded limits, but it's worth trying.
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( 300 );
/** @see PRAutoBlogger_Ajax_Handlers::on_ajax_test_connection() */
public function on_ajax_test_connection(): void { $this->ajax_handlers->on_ajax_test_connection(); }
// Allow admins to force-clear a stale lock.
$force = isset( $_POST['force'] ) && '1' === $_POST['force'];
if ( $force ) {
$this->release_generation_lock();
}
if ( ! $this->acquire_generation_lock() ) {
wp_send_json_error( [ 'message' => __( 'A generation run is already in progress. Pass force=1 to clear the lock.', 'prautoblogger' ) ] );
return;
}
try {
// Manual runs skip dedup so "Generate Now" always produces content
// even when a related article was recently published.
$results = ( new PRAutoBlogger_Pipeline_Runner() )
->set_skip_dedup( true )
->run();
wp_send_json_success( $results );
} catch ( \Exception $e ) {
PRAutoBlogger_Logger::instance()->error( 'Manual generation FAILED: ' . $e->getMessage(), 'pipeline' );
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( 'prautoblogger_test_connection', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
return;
}
$service = isset( $_POST['service'] ) ? sanitize_text_field( wp_unslash( $_POST['service'] ) ) : '';
$results = [];
if ( 'openrouter' === $service || 'all' === $service ) {
$llm = new PRAutoBlogger_OpenRouter_Provider();
$results['openrouter'] = $llm->validate_credentials_detailed();
}
if ( 'reddit' === $service || 'all' === $service ) {
$reddit = new PRAutoBlogger_Reddit_Provider();
$is_ok = $reddit->validate_credentials();
$results['reddit'] = $is_ok
? [ 'status' => 'ok', 'message' => __( 'Reddit source available (RSS primary, .json fallback).', 'prautoblogger' ) ]
: [ 'status' => 'error', 'message' => __( 'Reddit sources unreachable (both RSS and .json failed).', 'prautoblogger' ) ];
}
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 = 'prautoblogger_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;
}
/**
* Lazy-load the OpenRouter model registry singleton.
*
* Config (option names, transient names) is injected here — the registry
* class itself has no knowledge of PRAUTOBLOGGER_* constants.
*
* @return PRAutoBlogger_OpenRouter_Model_Registry
*/
public function get_model_registry(): PRAutoBlogger_OpenRouter_Model_Registry {
if ( null === $this->model_registry ) {
$this->model_registry = new PRAutoBlogger_OpenRouter_Model_Registry(
'prautoblogger_openrouter_model_registry',
'prautoblogger_openrouter_model_registry_cache'
);
}
return $this->model_registry;
}
/** 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",
'prautoblogger_generation_lock'
)
);
}
/** @see PRAutoBlogger_Executor::get_model_registry() */
public function get_model_registry(): PRAutoBlogger_OpenRouter_Model_Registry { return $this->executor->get_model_registry(); }
}

View File

@@ -1,136 +0,0 @@
<?php
declare(strict_types=1);
/**
* Creates / updates the plugin's custom database tables via `dbDelta`.
*
* Extracted from `PRAutoBlogger_Activator` in v0.8.2 to keep that class
* under the 300-line cap once the timezone-aware cron migration landed.
* The Activator delegates to `install()` on activate and on db-version
* upgrade; schema definitions live here and only here.
*
* Triggered by: PRAutoBlogger_Activator::activate().
* Dependencies: WordPress $wpdb, dbDelta() from wp-admin/includes/upgrade.php.
*
* @see class-activator.php — Sole caller; delegates on activate and db-version bump.
* @see ARCHITECTURE.md — Data model section documents each table's purpose.
*/
class PRAutoBlogger_Schema_Installer {
/**
* Create (or upgrade) all plugin tables.
*
* `dbDelta` is idempotent and will create new tables, add missing
* columns on existing tables, and add missing indexes — but it will
* not drop columns or indexes. That matches the plugin's forward-only
* schema-migration policy.
*
* Side effects: up to five `CREATE TABLE` / `ALTER TABLE` statements.
*
* @return void
*/
public static function install(): void {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$prefix = $wpdb->prefix . 'prautoblogger_';
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 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 );
}
}

View File

@@ -1,165 +0,0 @@
<?php
declare(strict_types=1);
/**
* Prompt construction for the content analysis LLM call.
*
* What: Builds system and user prompts for source-data analysis, including
* past-performance context for self-improvement feedback loops.
* Who calls it: PRAutoBlogger_Content_Analyzer::analyze_recent_data().
* Dependencies: WordPress $wpdb (for performance lookup), no external APIs.
*
* @see core/class-content-analyzer.php — Orchestrates analysis and calls these builders.
* @see ARCHITECTURE.md — Data flow step 2.
*/
class PRAutoBlogger_Analysis_Prompts {
/**
* Build the system prompt for content analysis.
*
* @param string $niche Niche description from settings.
* @param string $performance_context Past performance data summary.
* @param int $target_count Number of ideas the LLM should produce.
* @return string System prompt text.
*/
public static function build_system_prompt( string $niche, string $performance_context, int $target_count = 6 ): string {
$prompt = "You are a content strategist analyzing social media discussions to find article ideas for a blog";
if ( '' !== $niche ) {
$prompt .= " in the {$niche} niche";
}
$prompt .= ".\n\n";
$prompt .= "IMPORTANT: You must identify exactly {$target_count} DISTINCT article ideas. ";
$prompt .= "Each idea must cover a substantially different topic — no two ideas should overlap ";
$prompt .= "in their main subject. Aim for diversity across these categories:\n";
$prompt .= "1. QUESTIONS: Recurring questions people ask (\"How do I...\", \"What is...\", \"Is it safe to...\")\n";
$prompt .= "2. COMPLAINTS: Pain points, frustrations, or problems people report\n";
$prompt .= "3. COMPARISONS: Product/method comparisons people discuss (\"X vs Y\", \"Which is better\")\n";
$prompt .= "4. NEWS: Recent developments, rule changes, or trends people are discussing\n";
$prompt .= "5. GUIDES: How-to topics, best practices, or educational content people need\n\n";
$prompt .= "Spread your {$target_count} ideas across multiple categories. ";
$prompt .= "Be specific — \"peptide dosing for BPC-157\" is better than \"peptide information\". ";
$prompt .= "Each idea should be narrow enough to be a single focused blog post.\n\n";
$prompt .= "For each idea, provide:\n";
$prompt .= "- type: 'question', 'complaint', 'comparison', 'news', or 'guide'\n";
$prompt .= "- topic: A clear, specific, narrow topic description\n";
$prompt .= "- summary: 1-2 sentence summary of why this topic matters\n";
$prompt .= "- frequency: How many of the provided posts relate to this topic\n";
$prompt .= "- relevance_score: 0.0 to 1.0 indicating how relevant and article-worthy this is\n";
$prompt .= "- suggested_title: A compelling, unique blog post title\n";
$prompt .= "- key_points: Array of 3-5 key points the article should cover\n";
$prompt .= "- target_keywords: Array of SEO keywords for this topic\n\n";
if ( '' !== $performance_context ) {
$prompt .= $performance_context . "\n\n";
}
$recent_context = self::get_recent_articles_context();
if ( '' !== $recent_context ) {
$prompt .= $recent_context . "\n\n";
}
// Append user-defined analysis instructions if configured.
$instructions = trim( (string) get_option( 'prautoblogger_analysis_instructions', '' ) );
if ( '' !== $instructions ) {
$prompt .= "Additional instructions:\n" . $instructions . "\n\n";
}
$prompt .= "Respond with valid JSON containing exactly {$target_count} patterns:\n";
$prompt .= '{"patterns": [{"type": "...", "topic": "...", "summary": "...", "frequency": N, "relevance_score": 0.X, "suggested_title": "...", "key_points": [...], "target_keywords": [...]}]}';
return $prompt;
}
/**
* Build the user prompt containing the actual source data.
*
* @param string $summary Formatted source data summary.
* @param int $target_count Number of ideas requested.
* @return string User prompt text.
*/
public static function build_user_prompt( string $summary, int $target_count = 6 ): string {
return "Here are the recent social media posts and comments to analyze. "
. "Find {$target_count} distinct, diverse article ideas from this data:\n\n"
. $summary;
}
/**
* Get past content performance data to feed into analysis for self-improvement.
*
* Side effects: database read.
*
* @return string Summary of what topics performed well, or empty string if no data.
*/
public static function get_performance_context(): string {
global $wpdb;
$scores_table = $wpdb->prefix . 'prautoblogger_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 );
}
/**
* Get titles of recently published articles so the LLM avoids repeating them.
*
* Fetches generated posts from the last 30 days. A wider window than the
* scorer's 7-day dedup because the LLM is smart enough to find genuinely
* new angles even on adjacent topics — we just need it to know what exists.
*
* Side effects: database read via WP_Query.
*
* @return string Formatted context block, or empty string if no recent articles.
*/
public static function get_recent_articles_context(): string {
$query = new \WP_Query( [
'post_type' => 'post',
'post_status' => [ 'publish', 'draft', 'pending' ],
'meta_key' => '_prautoblogger_generated',
'meta_value' => '1',
'date_query' => [ [ 'after' => '30 days ago' ] ],
'posts_per_page' => 100,
'fields' => 'ids',
'no_found_rows' => true,
] );
if ( empty( $query->posts ) ) {
return '';
}
$titles = [];
foreach ( $query->posts as $post_id ) {
$titles[] = '- "' . get_the_title( $post_id ) . '"';
}
$block = "IMPORTANT — Topics already covered (last 30 days). Do NOT suggest topics that overlap with these. ";
$block .= "Find genuinely NEW angles, questions, or subjects that are NOT already covered:\n";
$block .= implode( "\n", $titles );
return $block;
}
}

View File

@@ -1,145 +0,0 @@
<?php
declare(strict_types=1);
/**
* Generates a single article: content → editorial review → publish/draft.
*
* Extracted from Pipeline_Runner so each chained cron job has a focused,
* single-responsibility worker. One worker = one article = one PHP process.
*
* Triggered by: Pipeline_Runner (directly for article 1, via cron for 2..N).
* Dependencies: Content_Generator, Chief_Editor, Publisher, Cost_Tracker.
*
* @see core/class-pipeline-runner.php — Orchestrates and dispatches workers.
* @see core/class-content-generator.php — LLM content generation.
* @see core/class-chief-editor.php — Editorial quality gate.
* @see core/class-publisher.php — WordPress post creation.
*/
class PRAutoBlogger_Article_Worker {
private PRAutoBlogger_Cost_Tracker $cost_tracker;
/**
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Shared cost tracker.
*/
public function __construct( PRAutoBlogger_Cost_Tracker $cost_tracker ) {
$this->cost_tracker = $cost_tracker;
}
/**
* Generate one article from an idea.
*
* Runs the full single-article pipeline: LLM content generation →
* editorial review → publish or save as draft.
*
* Side effects: LLM API calls, database writes, WordPress post creation,
* image generation, cost logging.
*
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate from.
*
* @return array{generated: int, published: int, rejected: int, cost: float}
*/
public function generate( PRAutoBlogger_Article_Idea $idea ): array {
$llm = new PRAutoBlogger_OpenRouter_Provider();
$generator = new PRAutoBlogger_Content_Generator( $llm, $this->cost_tracker );
$editor = new PRAutoBlogger_Chief_Editor( $llm, $this->cost_tracker );
$publisher = new PRAutoBlogger_Publisher();
$auto_publish = in_array(
get_option( 'prautoblogger_auto_publish', '0' ),
[ '1', 'yes' ],
true
);
$result = [
'generated' => 0,
'published' => 0,
'rejected' => 0,
'cost' => 0.0,
];
try {
$this->broadcast_stage( __( 'Generating article draft via AI…', 'prautoblogger' ) );
$content = $generator->generate( $idea );
$result['generated'] = 1;
$this->broadcast_stage( __( 'Running editorial pass…', 'prautoblogger' ) );
$review = $editor->review( $content, $idea );
$this->broadcast_stage( __( 'Saving and publishing…', 'prautoblogger' ) );
$this->publish_or_draft(
$content, $idea, $review, $publisher, $auto_publish, $result
);
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf(
'Article generation %s for "%s": %s',
get_class( $e ),
$idea->get_topic(),
$e->getMessage()
),
'pipeline'
);
}
$result['cost'] = $this->cost_tracker->get_current_run_cost();
return $result;
}
/**
* Publish approved content or save as draft.
*
* @param string $content Generated HTML content.
* @param PRAutoBlogger_Article_Idea $idea Source idea.
* @param PRAutoBlogger_Editorial_Review $review Editor verdict.
* @param PRAutoBlogger_Publisher $publisher Publisher instance.
* @param bool $auto_publish Whether to auto-publish.
* @param array &$result Result counters (by ref).
*/
private function publish_or_draft(
string $content,
PRAutoBlogger_Article_Idea $idea,
PRAutoBlogger_Editorial_Review $review,
PRAutoBlogger_Publisher $publisher,
bool $auto_publish,
array &$result
): void {
$verdict = $review->get_verdict();
$run_id = $this->cost_tracker->get_run_id();
if ( 'approved' === $verdict || 'revised' === $verdict ) {
$final = 'revised' === $verdict
? ( $review->get_revised_content() ?? $content )
: $content;
if ( $auto_publish ) {
$publisher->publish( $final, $idea, $review, $run_id );
$result['published']++;
} else {
$publisher->save_as_draft( $final, $idea, $review, $run_id );
}
} else {
$publisher->save_as_draft( $content, $idea, $review, $run_id );
$result['rejected']++;
PRAutoBlogger_Logger::instance()->info(
'Article rejected by editor: ' . $idea->get_topic(),
'pipeline'
);
}
}
/**
* Update the generation status transient with the current stage.
*
* @param string $stage Human-readable stage description.
*/
private function broadcast_stage( string $stage ): void {
$transient_key = 'prautoblogger_generation_status';
$current = get_transient( $transient_key );
if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) {
$current['stage'] = $stage;
$current['last_updated'] = time();
set_transient( $transient_key, $current, 600 );
}
}
}

View File

@@ -12,7 +12,6 @@ declare(strict_types=1);
* Dependencies: LLM_Provider_Interface, Cost_Tracker.
*
* @see core/class-content-generator.php — Produces the content we review.
* @see core/class-json-extractor.php — Tolerant JSON parsing for LLM output.
* @see core/class-publisher.php — Publishes approved content.
* @see ARCHITECTURE.md — Data flow step 5.
*/
@@ -109,13 +108,6 @@ class PRAutoBlogger_Chief_Editor {
$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";
$prompt .= "- When revising, PRESERVE all bullet points, numbered lists, hyperlinks, and structural formatting from the original. Do NOT flatten lists into prose or remove links.\n";
// Append user-defined editor instructions if configured.
$instructions = trim( (string) get_option( 'prautoblogger_editor_instructions', '' ) );
if ( '' !== $instructions ) {
$prompt .= "\nAdditional instructions:\n" . $instructions . "\n";
}
return $prompt;
}
@@ -153,14 +145,10 @@ class PRAutoBlogger_Chief_Editor {
* @return PRAutoBlogger_Editorial_Review
*/
private function parse_review_response( string $content ): PRAutoBlogger_Editorial_Review {
$data = PRAutoBlogger_Json_Extractor::decode( $content );
$data = json_decode( $content, true );
if ( ! is_array( $data ) || ! isset( $data['verdict'] ) ) {
PRAutoBlogger_Logger::instance()->error(
'Chief editor response was not valid JSON. Defaulting to rejected. Raw (first 500 chars): '
. mb_substr( $content, 0, 500 ),
'editor'
);
PRAutoBlogger_Logger::instance()->error( 'Chief editor response was not valid JSON. Defaulting to rejected.', 'editor' );
return new PRAutoBlogger_Editorial_Review( [
'verdict' => 'rejected',
'notes' => 'Editor response could not be parsed.',

View File

@@ -7,17 +7,14 @@ declare(strict_types=1);
*
* Uses a cheap model (configurable) to keep analysis costs low.
* Batches source data into manageable chunks for the LLM context window.
* Prompt construction is delegated to PRAutoBlogger_Analysis_Prompts.
*
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 2).
* Dependencies: LLM_Provider_Interface, Cost_Tracker, PRAutoBlogger_Analysis_Prompts, WordPress $wpdb.
* Dependencies: LLM_Provider_Interface, Cost_Tracker, WordPress $wpdb.
*
* @see core/class-analysis-prompts.php — System/user prompt builders + performance context.
* @see providers/interface-llm-provider.php — LLM used for analysis.
* @see core/class-cost-tracker.php — Logs API costs.
* @see core/class-json-extractor.php — Tolerant JSON parsing for LLM output.
* @see core/class-idea-scorer.php — Consumes analysis results.
* @see ARCHITECTURE.md — Data flow step 2.
* @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 PRAutoBlogger_Content_Analyzer {
@@ -40,9 +37,11 @@ class PRAutoBlogger_Content_Analyzer {
*
* Side effects: LLM API call, database reads/writes.
*
* @return PRAutoBlogger_Analysis_Result[] Detected patterns.
*/
/**
* @param int $target_idea_count How many distinct topic ideas the LLM should produce.
* Defaults to 6 so the scorer has enough to pick from.
* @return PRAutoBlogger_Analysis_Result[] Detected patterns.
*/
public function analyze_recent_data( int $target_idea_count = 6 ): array {
$source_items = $this->fetch_recent_source_data();
@@ -55,10 +54,14 @@ class PRAutoBlogger_Content_Analyzer {
$niche = get_option( 'prautoblogger_niche_description', '' );
$model = get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
$summary = $this->build_source_summary( $source_items );
$performance_context = PRAutoBlogger_Analysis_Prompts::get_performance_context();
$system_prompt = PRAutoBlogger_Analysis_Prompts::build_system_prompt( $niche, $performance_context, $target_idea_count );
$user_prompt = PRAutoBlogger_Analysis_Prompts::build_user_prompt( $summary, $target_idea_count );
// 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, $target_idea_count );
$user_prompt = $this->build_user_prompt( $summary, $target_idea_count );
$response = $this->llm->send_chat_completion(
[
@@ -73,6 +76,7 @@ class PRAutoBlogger_Content_Analyzer {
]
);
// Log the cost.
$this->cost_tracker->log_api_call(
null,
'analysis',
@@ -82,38 +86,15 @@ class PRAutoBlogger_Content_Analyzer {
$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;
}
// ── Backward-compatible proxies (prompt building moved to Analysis_Prompts) ──
/**
* @deprecated Use PRAutoBlogger_Analysis_Prompts::build_system_prompt() directly.
* @see core/class-analysis-prompts.php
*/
private function build_system_prompt( string $niche, string $performance_context, int $target_count = 6 ): string {
return PRAutoBlogger_Analysis_Prompts::build_system_prompt( $niche, $performance_context, $target_count );
}
/**
* @deprecated Use PRAutoBlogger_Analysis_Prompts::build_user_prompt() directly.
* @see core/class-analysis-prompts.php
*/
private function build_user_prompt( string $summary, int $target_count = 6 ): string {
return PRAutoBlogger_Analysis_Prompts::build_user_prompt( $summary, $target_count );
}
/**
* @deprecated Use PRAutoBlogger_Analysis_Prompts::get_performance_context() directly.
* @see core/class-analysis-prompts.php
*/
private function get_performance_context(): string {
return PRAutoBlogger_Analysis_Prompts::get_performance_context();
}
/**
* Fetch source data collected in the last 24 hours.
*
@@ -139,6 +120,7 @@ class PRAutoBlogger_Content_Analyzer {
* 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 {
@@ -147,14 +129,12 @@ class PRAutoBlogger_Content_Analyzer {
$current_chars = 0;
foreach ( $items as $item ) {
// LLM research findings have no subreddit; use a cleaner label.
$source_label = 'llm_research' === $item['source_type']
? sprintf( '[LLM Research] Relevance: %d', (int) $item['score'] )
: sprintf( '[%s] r/%s | Score: %d | Comments: %d', $item['source_type'], $item['subreddit'] ?? 'unknown', (int) $item['score'], (int) $item['comment_count'] );
$entry = sprintf(
"%s\nTitle: %s\n%s\n---",
$source_label,
"[%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 )
);
@@ -170,21 +150,115 @@ class PRAutoBlogger_Content_Analyzer {
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 . 'prautoblogger_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, int $target_count = 6 ): string {
$prompt = "You are a content strategist analyzing social media discussions to find article ideas for a blog";
if ( '' !== $niche ) {
$prompt .= " in the {$niche} niche";
}
$prompt .= ".\n\n";
$prompt .= "IMPORTANT: You must identify exactly {$target_count} DISTINCT article ideas. ";
$prompt .= "Each idea must cover a substantially different topic — no two ideas should overlap ";
$prompt .= "in their main subject. Aim for diversity across these categories:\n";
$prompt .= "1. QUESTIONS: Recurring questions people ask (\"How do I...\", \"What is...\", \"Is it safe to...\")\n";
$prompt .= "2. COMPLAINTS: Pain points, frustrations, or problems people report\n";
$prompt .= "3. COMPARISONS: Product/method comparisons people discuss (\"X vs Y\", \"Which is better\")\n";
$prompt .= "4. NEWS: Recent developments, rule changes, or trends people are discussing\n";
$prompt .= "5. GUIDES: How-to topics, best practices, or educational content people need\n\n";
$prompt .= "Spread your {$target_count} ideas across multiple categories. ";
$prompt .= "Be specific — \"peptide dosing for BPC-157\" is better than \"peptide information\". ";
$prompt .= "Each idea should be narrow enough to be a single focused blog post.\n\n";
$prompt .= "For each idea, provide:\n";
$prompt .= "- type: 'question', 'complaint', 'comparison', 'news', or 'guide'\n";
$prompt .= "- topic: A clear, specific, narrow topic description\n";
$prompt .= "- summary: 1-2 sentence summary of why this topic matters\n";
$prompt .= "- frequency: How many of the provided posts relate to this topic\n";
$prompt .= "- relevance_score: 0.0 to 1.0 indicating how relevant and article-worthy this is\n";
$prompt .= "- suggested_title: A compelling, unique blog post title\n";
$prompt .= "- key_points: Array of 3-5 key points the article should cover\n";
$prompt .= "- target_keywords: Array of SEO keywords for this topic\n\n";
if ( '' !== $performance_context ) {
$prompt .= $performance_context . "\n\n";
}
$prompt .= "Respond with valid JSON containing exactly {$target_count} patterns:\n";
$prompt .= '{"patterns": [{"type": "...", "topic": "...", "summary": "...", "frequency": N, "relevance_score": 0.X, "suggested_title": "...", "key_points": [...], "target_keywords": [...]}]}';
return $prompt;
}
/**
* 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, int $target_count = 6 ): string {
return "Here are the recent social media posts and comments to analyze. "
. "Find {$target_count} distinct, diverse article ideas from this data:\n\n"
. $summary;
}
/**
* 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.
* @param string $content LLM response content (JSON).
* @param array<int, array<string, mixed>> $source_items Original source data for ID mapping.
*
* @return PRAutoBlogger_Analysis_Result[]
*/
private function parse_analysis_response( string $content, array $source_items ): array {
$data = PRAutoBlogger_Json_Extractor::decode( $content );
$data = json_decode( $content, true );
if ( ! isset( $data['patterns'] ) || ! is_array( $data['patterns'] ) ) {
PRAutoBlogger_Logger::instance()->error(
'Analysis response was not valid JSON or missing patterns key. Raw (first 500 chars): '
. mb_substr( $content, 0, 500 ),
'analyzer'
);
PRAutoBlogger_Logger::instance()->error( 'Analysis response was not valid JSON or missing patterns key.', 'analyzer' );
return [];
}
@@ -217,6 +291,8 @@ class PRAutoBlogger_Content_Analyzer {
* Side effects: database inserts.
*
* @param PRAutoBlogger_Analysis_Result[] $results
*
* @return void
*/
private function store_results( array $results ): void {
global $wpdb;

View File

@@ -9,12 +9,11 @@ declare(strict_types=1);
* - multi_step: Outline → Draft → Polish pipeline (higher quality, more cost).
*
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 4).
* Dependencies: LLM_Provider_Interface, Cost_Tracker, Content_Prompts.
* Dependencies: LLM_Provider_Interface, Cost_Tracker.
*
* @see core/class-content-prompts.php — Builds all LLM prompts used here.
* @see core/class-chief-editor.php — Reviews output of this class.
* @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.
* @see ARCHITECTURE.md — Data flow step 4.
*/
class PRAutoBlogger_Content_Generator {
@@ -33,10 +32,13 @@ class PRAutoBlogger_Content_Generator {
* 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 PRAutoBlogger_Article_Idea $idea The scored idea to generate content for.
*
* @return string Generated HTML content.
*
* @throws \RuntimeException On generation failure.
*/
public function generate( PRAutoBlogger_Article_Idea $idea ): string {
@@ -49,8 +51,7 @@ class PRAutoBlogger_Content_Generator {
absint( get_option( 'prautoblogger_min_word_count', 800 ) ),
absint( get_option( 'prautoblogger_max_word_count', 2000 ) ),
get_option( 'prautoblogger_niche_description', '' ),
json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: [],
get_option( 'prautoblogger_writing_instructions', '' )
json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: []
);
if ( 'single_pass' === $mode ) {
@@ -63,48 +64,94 @@ class PRAutoBlogger_Content_Generator {
/**
* Single-pass generation: one LLM call produces the complete article.
*
* @param PRAutoBlogger_Content_Request $request Content request with settings.
* @param PRAutoBlogger_Content_Request $request
*
* @return string Generated HTML content.
*/
private function generate_single_pass( PRAutoBlogger_Content_Request $request ): string {
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_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' => PRAutoBlogger_Content_Prompts::build_system( $request ) ],
[ 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_single_pass( $request ) ],
[ 'role' => 'system', 'content' => $system ],
[ 'role' => 'user', 'content' => $user ],
],
$model,
[ 'temperature' => 0.7, 'max_tokens' => 4000 ]
[
'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']
null,
'draft',
$this->llm->get_provider_name(),
$response['model'],
$response['prompt_tokens'],
$response['completion_tokens']
);
return $response['content'];
}
/** Multi-step generation: Outline → Draft → Polish. */
/**
* Multi-step generation: Outline → Draft → Polish.
*
* @param PRAutoBlogger_Content_Request $request
*
* @return string Generated HTML content.
*/
private function generate_multi_step( PRAutoBlogger_Content_Request $request ): string {
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
$outline = $this->stage_outline( $request, $model );
$draft = $this->stage_draft( $request, $outline, $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. */
/**
* Multi-step stage 1: Generate an article outline.
*
* @param PRAutoBlogger_Content_Request $request
* @param string $model
*
* @return string The outline text.
*/
private function stage_outline( PRAutoBlogger_Content_Request $request, string $model ): string {
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
$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' => $system ],
[ 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_outline( $request ) ],
[ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ],
[ 'role' => 'user', 'content' => $prompt ],
],
$model,
[ 'temperature' => 0.5, 'max_tokens' => 1500 ]
@@ -118,14 +165,38 @@ class PRAutoBlogger_Content_Generator {
return $response['content'];
}
/** Multi-step stage 2: Write the full draft from the outline. */
/**
* Multi-step stage 2: Write the full draft from the outline.
*
* @param PRAutoBlogger_Content_Request $request
* @param string $outline
* @param string $model
*
* @return string Draft HTML content.
*/
private function stage_draft( PRAutoBlogger_Content_Request $request, string $outline, string $model ): string {
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
$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' => $system ],
[ 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_draft( $request, $outline ) ],
[ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ],
[ 'role' => 'user', 'content' => $prompt ],
],
$model,
[ 'temperature' => 0.7, 'max_tokens' => 4000 ]
@@ -139,14 +210,29 @@ class PRAutoBlogger_Content_Generator {
return $response['content'];
}
/** Multi-step stage 3: Polish and refine the draft. */
/**
* Multi-step stage 3: Polish and refine the draft.
*
* @param PRAutoBlogger_Content_Request $request
* @param string $draft
* @param string $model
*
* @return string Polished HTML content.
*/
private function stage_polish( PRAutoBlogger_Content_Request $request, string $draft, string $model ): string {
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
$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' => $system ],
[ 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_polish( $draft ) ],
[ 'role' => 'system', 'content' => $this->build_writer_system_prompt( $request ) ],
[ 'role' => 'user', 'content' => $prompt ],
],
$model,
[ 'temperature' => 0.4, 'max_tokens' => 4000 ]
@@ -159,4 +245,53 @@ class PRAutoBlogger_Content_Generator {
return $response['content'];
}
/**
* Build the system prompt shared across all writing stages.
*
* @param PRAutoBlogger_Content_Request $request
*
* @return string
*/
private function build_writer_system_prompt( PRAutoBlogger_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 PRAutoBlogger_Content_Request $request
*
* @return string
*/
private function build_single_pass_prompt( PRAutoBlogger_Content_Request $request ): string {
$idea = $request->get_idea();
return sprintf(
"Write a complete blog post in HTML format.\n\n" .
"Title: %s\nTopic: %s\nType: %s\n\nKey points:\n- %s\n\n" .
"Keywords: %s\n\n" .
"Requirements:\n" .
"- %d-%d words\n" .
"- Proper HTML (h2, h3, p, ul/li)\n" .
"- Engaging intro, strong conclusion with CTA\n" .
"- Do NOT include the title or <html>/<body> tags\n" .
"- Output HTML only, no markdown or commentary",
$idea->get_suggested_title(),
$idea->get_topic(),
$idea->get_article_type(),
implode( "\n- ", $idea->get_key_points() ),
implode( ', ', $idea->get_target_keywords() ),
$request->get_min_word_count(),
$request->get_max_word_count()
);
}
}

View File

@@ -1,211 +0,0 @@
<?php
declare(strict_types=1);
/**
* Builds all LLM prompts used by the content generation pipeline.
*
* Centralises prompt construction so Content_Generator stays lean and
* prompt logic (system prompt, stage prompts, linking rules) can evolve
* independently of the generation orchestration.
*
* Triggered by: PRAutoBlogger_Content_Generator (called for every stage).
* Dependencies: WordPress query functions (get_posts, get_the_title, get_permalink).
*
* @see core/class-content-generator.php — Consumer of these prompts.
* @see models/class-content-request.php — Data bag passed to every builder.
*/
class PRAutoBlogger_Content_Prompts {
/**
* Build the system prompt shared across all writing stages.
*
* Includes niche, tone, mandatory style guide, internal link reference,
* peptide database links, and the "never fabricate URLs" rule.
*
* @param PRAutoBlogger_Content_Request $request Content request with settings.
* @return string Complete system prompt.
*/
public static function build_system( PRAutoBlogger_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.';
// Append user-defined writing instructions as a mandatory style guide.
$instructions = trim( $request->get_writing_instructions() );
if ( '' !== $instructions ) {
$prompt .= "\n\n--- MANDATORY STYLE GUIDE ---\n";
$prompt .= "You MUST follow every requirement below. These override any conflicting defaults:\n\n";
$prompt .= $instructions;
$prompt .= "\n--- END STYLE GUIDE ---";
}
$prompt .= "\n\n" . self::build_linking_rules();
return $prompt;
}
/**
* Build the user prompt for single-pass generation.
*
* @param PRAutoBlogger_Content_Request $request Content request with settings.
* @return string User prompt.
*/
public static function build_single_pass( PRAutoBlogger_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\n" .
"- Follow EVERY formatting and structural requirement from your system prompt style guide",
$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()
);
}
/**
* Build the outline stage prompt (multi-step stage 1).
*
* @param PRAutoBlogger_Content_Request $request Content request with settings.
* @return string Outline prompt.
*/
public static function build_outline( PRAutoBlogger_Content_Request $request ): string {
$idea = $request->get_idea();
return 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.\n\n" .
"Plan the structure to satisfy EVERY requirement in your system prompt style guide.",
$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()
);
}
/**
* Build the draft stage prompt (multi-step stage 2).
*
* @param PRAutoBlogger_Content_Request $request Content request with settings.
* @param string $outline The outline from stage 1.
* @return string Draft prompt.
*/
public static function build_draft( PRAutoBlogger_Content_Request $request, string $outline ): string {
return 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\n" .
"- Follow EVERY formatting and structural requirement from your system prompt style guide",
$outline,
$request->get_tone(),
$request->get_min_word_count(),
$request->get_max_word_count(),
implode( ', ', $request->get_idea()->get_target_keywords() )
);
}
/**
* Build the polish stage prompt (multi-step stage 3).
*
* @param string $draft The draft from stage 2.
* @return string Polish prompt.
*/
public static function build_polish( string $draft ): string {
return "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" .
"IMPORTANT: Preserve all bullet points, numbered lists, hyperlinks, and " .
"structural elements from the draft. Do NOT flatten lists into prose or " .
"remove links. Ensure every requirement from your system prompt style " .
"guide is satisfied in the final output.\n\n" .
"Return the polished HTML content only. Do not add commentary.\n\n" .
"DRAFT:\n" . $draft;
}
/**
* Build the linking rules section (internal articles + peptide database).
*
* Fetches published blog posts and peptide pages, formats them as a
* reference list so the model uses real URLs instead of fabricating them.
*
* @return string Complete linking rules block.
*/
private static function build_linking_rules(): string {
$rules = "--- LINKING RULES ---\n";
$rules .= "NEVER fabricate or invent URLs. You do NOT have access to external sources.\n";
$rules .= "Only use the internal links listed below when linking within the article.\n";
$rules .= "If no listed article is relevant to a section, do not insert a link.\n";
$rules .= "Do NOT add links for peptide names — those are injected automatically after generation.\n\n";
$rules .= self::build_article_links();
$rules .= "--- END LINKING RULES ---";
return $rules;
}
/**
* Build a reference list of published blog posts for internal linking.
*
* @return string Formatted list, or a fallback note.
*/
private static function build_article_links(): string {
$posts = get_posts( [
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => 30,
'orderby' => 'date',
'order' => 'DESC',
'fields' => 'ids',
] );
if ( empty( $posts ) ) {
return "No published articles available for internal linking yet.\n";
}
$lines = [ "Available article links (use where topically relevant):\n" ];
foreach ( $posts as $post_id ) {
$title = get_the_title( $post_id );
$url = get_permalink( $post_id );
if ( $title && $url ) {
$lines[] = "- {$title}: {$url}";
}
}
return implode( "\n", $lines ) . "\n";
}
}

View File

@@ -1,141 +0,0 @@
<?php
declare(strict_types=1);
/**
* Provides historical cost reporting and analysis methods.
*
* Extracted from PRAutoBlogger_Cost_Tracker, this class handles all read-only
* reporting queries: monthly spend, daily spend trends, spend-by-stage breakdowns,
* and budget utilization percentage.
*
* Called by: Metrics dashboard, admin notices, and dashboard widget.
* Dependencies: WordPress $wpdb, get_option() for budget configuration.
*
* @see core/class-cost-tracker.php — Complementary class for logging and budget enforcement.
* @see admin/class-metrics-page.php — Displays cost reports.
* @see admin/class-admin-notices.php — Shows budget warnings via get_budget_utilization().
* @see admin/class-dashboard-widget.php — Displays current month costs.
*/
class PRAutoBlogger_Cost_Reporter {
/**
* Get total estimated spend for the current calendar month.
*
* @return float Total USD spend this month.
*/
public function get_monthly_spend(): float {
global $wpdb;
if ( null === $wpdb ) {
return 0.0;
}
$table = $wpdb->prefix . 'prautoblogger_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 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;
if ( null === $wpdb ) {
return [];
}
$table = $wpdb->prefix . 'prautoblogger_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;
if ( null === $wpdb ) {
return [];
}
$table = $wpdb->prefix . 'prautoblogger_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( 'prautoblogger_monthly_budget_usd', 50.00 );
if ( $budget <= 0 ) {
return 0.0;
}
return ( $this->get_monthly_spend() / $budget ) * 100.0;
}
}

View File

@@ -2,7 +2,8 @@
declare(strict_types=1);
/**
* Tracks all API costs and enforces monthly budget limits.
* 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
@@ -15,8 +16,7 @@ declare(strict_types=1);
* @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 core/class-cost-reporter.php — Extracted reporting methods (read-only queries).
* @see admin/class-metrics-page.php — Displays cost data via Cost_Reporter.
* @see admin/class-metrics-page.php — Displays cost data from this class.
* @see ARCHITECTURE.md — prab_generation_log table schema.
*/
class PRAutoBlogger_Cost_Tracker {
@@ -127,8 +127,7 @@ class PRAutoBlogger_Cost_Tracker {
return false;
}
$reporter = new PRAutoBlogger_Cost_Reporter();
$monthly_spend = $reporter->get_monthly_spend();
$monthly_spend = $this->get_monthly_spend();
// Round to 4 decimal places (0.01 cent precision) to avoid
// floating-point representation artifacts triggering false positives.
@@ -136,63 +135,25 @@ class PRAutoBlogger_Cost_Tracker {
}
/**
* Log an image generation API call with a known cost.
* Get total estimated spend for the current calendar month.
*
* Unlike log_api_call() which calculates cost from token counts, image
* generation uses per-megapixel pricing that the image provider already
* computed. This method accepts the pre-calculated cost directly.
*
* @param float $cost_usd Estimated cost in USD (from image provider pricing).
* @param string $model Image model alias (e.g. 'flux-1-schnell').
* @param int $post_id WordPress post ID the image is attached to.
* @param string $stage Pipeline stage ('image_a' or 'image_b').
* @return void
* @return float Total USD spend this month.
*/
public function log_image_generation( float $cost_usd, string $model, int $post_id, string $stage ): void {
$this->current_run_cost += $cost_usd;
public function get_monthly_spend(): float {
global $wpdb;
if ( null === $wpdb ) {
return;
}
$table = $wpdb->prefix . 'prautoblogger_generation_log';
$first_of_month = gmdate( 'Y-m-01 00:00:00' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert( $table, [
'post_id' => $post_id,
'run_id' => $this->run_id,
'stage' => $stage,
'provider' => 'cloudflare-workers-ai',
'model' => $model,
'prompt_tokens' => 0,
'completion_tokens' => 0,
'estimated_cost' => $cost_usd,
'response_status' => 'success',
'error_message' => '',
'created_at' => current_time( 'mysql' ),
] );
}
$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
)
);
/**
* Check if adding an estimated cost would exceed the monthly budget.
*
* Unlike is_budget_exceeded() which checks current state, this method
* proactively checks whether a planned expenditure would push spend
* over the budget. Used by the image pipeline to pre-check before
* making an API call.
*
* @param float $estimated_cost_usd Estimated cost in USD for the planned operation.
* @return bool True if (current spend + estimated cost) >= configured budget.
*/
public function would_exceed_budget( float $estimated_cost_usd ): bool {
$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
if ( $budget <= 0 ) {
return false;
}
$reporter = new PRAutoBlogger_Cost_Reporter();
$projected = $reporter->get_monthly_spend() + $estimated_cost_usd;
return round( $projected, 4 ) >= round( $budget, 4 );
return (float) $result;
}
/**
@@ -203,4 +164,91 @@ class PRAutoBlogger_Cost_Tracker {
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 . 'prautoblogger_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 . 'prautoblogger_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( 'prautoblogger_monthly_budget_usd', 50.00 );
if ( $budget <= 0 ) {
return 0.0;
}
return ( $this->get_monthly_spend() / $budget ) * 100.0;
}
}

View File

@@ -4,25 +4,25 @@ declare(strict_types=1);
/**
* Ranks and deduplicates article ideas from analysis results.
*
* Uses PRAutoBlogger_Semantic_Dedup for similarity detection — embedding-based
* cosine similarity with automatic fallback to keyword overlap if the embedding
* API is unavailable.
* 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: PRAutoBlogger_Pipeline_Runner (step 3).
* Dependencies: Semantic_Dedup, WP_Query.
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 3).
* Dependencies: WordPress $wpdb, WP_Query.
*
* @see core/class-semantic-dedup.php — Embedding + keyword dedup engine.
* @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 PRAutoBlogger_Idea_Scorer {
/** @var bool Whether to skip deduplication entirely. */
/** @var bool Whether to skip the has_similar_post() check. */
private bool $skip_dedup = false;
/**
* @param bool $skip True to skip deduplication.
*
* @return $this
*/
public function set_skip_dedup( bool $skip ): self {
@@ -33,60 +33,29 @@ class PRAutoBlogger_Idea_Scorer {
/**
* Score, deduplicate, and rank article ideas.
*
* Initializes the semantic dedup engine once, then checks each candidate
* against (1) intra-batch duplicates and (2) recently published posts.
*
* Side effects: embedding API calls (via Semantic_Dedup), WP_Query.
*
* @param PRAutoBlogger_Analysis_Result[] $analysis_results Raw analysis output.
* @param int $target_count Max ideas to return.
* @param int $target_count Number of ideas to return.
*
* @return PRAutoBlogger_Article_Idea[] Top-ranked ideas, sorted by score desc.
* @return PRAutoBlogger_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( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: [];
$ideas = [];
$excluded_count = 0;
$deduped_count = 0;
$dedup = new PRAutoBlogger_Semantic_Dedup();
if ( ! $this->skip_dedup ) {
$dedup->initialize();
}
// Keyword sets kept for fallback (passed through to Semantic_Dedup).
$accepted_keyword_sets = [];
$exclusions = json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: [];
$ideas = [];
foreach ( $analysis_results as $result ) {
// Skip excluded topics.
if ( $this->is_excluded( $result->get_topic(), $exclusions ) ) {
$excluded_count++;
continue;
}
if ( ! $this->skip_dedup ) {
$topic = $result->get_topic();
$topic_keywords = $this->extract_meaningful_keywords( $topic );
if ( $dedup->is_batch_duplicate( $topic, $topic_keywords, $accepted_keyword_sets ) ) {
$deduped_count++;
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Dedup(batch): skipping "%s"', substr( $topic, 0, 80 ) ),
'scorer'
);
continue;
}
if ( $dedup->is_recent_duplicate( $topic, $topic_keywords ) ) {
$deduped_count++;
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Dedup(recent): skipping "%s"', substr( $topic, 0, 80 ) ),
'scorer'
);
continue;
}
$accepted_keyword_sets[] = $topic_keywords;
$dedup->record_accepted( $topic );
// Skip if we've already published something very similar
// (unless dedup is disabled for manual runs).
if ( ! $this->skip_dedup && $this->has_similar_post( $result->get_topic() ) ) {
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Skipping topic "%s" — similar post exists.', substr( $result->get_topic(), 0, 80 ) ),
'scorer'
);
continue;
}
$metadata = $result->get_metadata() ?? [];
@@ -104,18 +73,7 @@ class PRAutoBlogger_Idea_Scorer {
] );
}
PRAutoBlogger_Logger::instance()->info(
sprintf(
'Scorer: %d input → %d viable, %d deduped, %d excluded. Returning top %d.',
count( $analysis_results ),
count( $ideas ),
$deduped_count,
$excluded_count,
min( $target_count, count( $ideas ) )
),
'scorer'
);
// Sort by score descending.
usort( $ideas, static function ( PRAutoBlogger_Article_Idea $a, PRAutoBlogger_Article_Idea $b ): int {
return $b->get_score() <=> $a->get_score();
} );
@@ -129,12 +87,14 @@ class PRAutoBlogger_Idea_Scorer {
* Factors: LLM relevance score (50%), frequency (30%), recency bonus (20%).
*
* @param PRAutoBlogger_Analysis_Result $result
*
* @return float Score between 0 and 1.
*/
private function compute_score( PRAutoBlogger_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 ) );
@@ -146,6 +106,7 @@ class PRAutoBlogger_Idea_Scorer {
*
* @param string $topic Topic text.
* @param string[] $exclusions List of excluded terms/phrases.
*
* @return bool
*/
private function is_excluded( string $topic, array $exclusions ): bool {
@@ -159,41 +120,33 @@ class PRAutoBlogger_Idea_Scorer {
}
/**
* Extract meaningful keywords from a text string (for fallback dedup).
* Check if a substantially similar post already exists.
*
* @param string $text Input text.
* @return string[] Unique lowercase keywords.
* 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 extract_meaningful_keywords( string $text ): array {
static $stopwords = [
'a', 'an', 'the', 'and', 'or', 'but', 'is', 'are', 'was', 'were',
'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'as',
'it', 'its', 'this', 'that', 'your', 'you', 'how', 'what', 'when',
'why', 'where', 'which', 'who', 'do', 'does', 'did', 'not', 'no',
'can', 'will', 'should', 'could', 'would', 'may', 'might', 'have',
'has', 'had', 'be', 'been', 'being', 'about', 'into', 'through',
'during', 'before', 'after', 'above', 'below', 'between', 'same',
'up', 'down', 'out', 'off', 'over', 'under', 'again', 'then',
'here', 'there', 'all', 'each', 'every', 'both', 'few', 'more',
'most', 'other', 'some', 'such', 'than', 'too', 'very', 'just',
'also', 'only', 'own', 'so', 'if', 'while', 'because', 'until',
'vs', 'versus', 'guide', 'complete', 'ultimate', 'best', 'top',
'new', 'first', 'need', 'know', 'everything',
];
private function has_similar_post( string $topic ): bool {
$query = new \WP_Query( [
'post_type' => 'post',
'post_status' => [ 'publish', 'draft', 'pending' ],
'meta_key' => '_prautoblogger_generated',
'meta_value' => '1',
's' => $topic,
'posts_per_page' => 1,
'fields' => 'ids',
] );
$text = strtolower( $text );
$words = preg_split( '/[^a-z0-9-]+/', $text, -1, PREG_SPLIT_NO_EMPTY );
$words = array_filter( $words, static function ( string $w ) use ( $stopwords ): bool {
return strlen( $w ) >= 2 && ! in_array( $w, $stopwords, true );
} );
return array_values( array_unique( $words ) );
return $query->found_posts > 0;
}
/**
* Map analysis type to a reader-friendly article type.
*
* @param string $analysis_type 'question', 'complaint', 'comparison', etc.
* @param string $analysis_type 'question', 'complaint', 'comparison'.
*
* @return string Article type label.
*/
private function map_type( string $analysis_type ): string {

View File

@@ -38,17 +38,7 @@ class PRAutoBlogger_Image_Media_Sideloader {
*
* @return int|\WP_Error Attachment ID on success, WP_Error on failure.
*/
public function sideload_image( array $image_data, int $post_id, string $alt_text = '' ) {
// WordPress media functions live in wp-admin/includes/ and are NOT
// loaded automatically in cron or REST contexts. Load them if missing.
// Why: WP-Cron runs as a frontend request, so admin-only files like
// media.php, file.php, and image.php are not included by default.
if ( ! function_exists( 'media_handle_sideload' ) ) {
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
}
public function sideload_image( array $image_data, int $post_id, string $alt_text = '' ): int | \WP_Error {
// Validate input.
if ( empty( $image_data['bytes'] ) ) {
return new \WP_Error( 'invalid_image_data', 'Image data (bytes) is empty.' );
@@ -107,7 +97,7 @@ class PRAutoBlogger_Image_Media_Sideloader {
*
* @return string|\WP_Error Path to temporary file on success, WP_Error on failure.
*/
private function create_temp_file( string $bytes, string $mime_type ) {
private function create_temp_file( string $bytes, string $mime_type ): string | \WP_Error {
$temp_dir = get_temp_dir();
$filename = 'prautoblogger_img_' . wp_generate_uuid4() . $this->get_extension_for_mime( $mime_type );
$temp_path = $temp_dir . $filename;
@@ -145,7 +135,7 @@ class PRAutoBlogger_Image_Media_Sideloader {
gmdate( 'Y-m-d_H-i-s' )
);
return $base . '.png'; // Default to PNG — most image models output PNG.
return $base . '.png'; // Default to PNG since FLUX.1 outputs PNG.
}
/**

View File

@@ -1,139 +0,0 @@
<?php
declare(strict_types=1);
/**
* Single-shot retry of NSFW-blocked image slots with a sanitized prompt.
*
* When an image provider rejects a prompt via its content filter, its
* batch result carries
* `['error_type' => 'nsfw_blocked']`. This helper reruns just the blocked
* slot once with the rule-based fallback prompt (article title + style
* suffix, no LLM rewrite) and merges the new result back into the batch.
*
* Intentionally single-retry: if the fallback is also blocked, we log a
* WARNING and leave the `['error' => ...]` entry in place so the pipeline
* proceeds to publish without a featured image (current behaviour for any
* unrecoverable image failure).
*
* Gated behind the `prautoblogger_image_nsfw_retry` setting (default on).
*
* Triggered by: PRAutoBlogger_Image_Pipeline::generate_and_attach_images.
* Dependencies: PRAutoBlogger_Image_Provider_Interface (retry HTTP call),
* PRAutoBlogger_Image_Prompt_Builder (fallback prompt),
* PRAutoBlogger_Logger (warning + info lines).
*
* @see core/class-image-pipeline.php — Sole caller.
* @see core/class-image-prompt-builder.php — Supplies the fallback prompt.
* @see providers/class-image-nsfw-blocked.php — Typed exception raised by providers.
*/
class PRAutoBlogger_Image_NSFW_Retry {
/**
* Option key for the admin toggle.
*/
private const SETTING_KEY = 'prautoblogger_image_nsfw_retry';
/** @var PRAutoBlogger_Image_Provider_Interface */
private PRAutoBlogger_Image_Provider_Interface $provider;
/** @var PRAutoBlogger_Image_Prompt_Builder */
private PRAutoBlogger_Image_Prompt_Builder $prompt_builder;
/**
* @param PRAutoBlogger_Image_Provider_Interface $provider Provider to retry against.
* @param PRAutoBlogger_Image_Prompt_Builder $prompt_builder Fallback prompt source.
*/
public function __construct(
PRAutoBlogger_Image_Provider_Interface $provider,
PRAutoBlogger_Image_Prompt_Builder $prompt_builder
) {
$this->provider = $provider;
$this->prompt_builder = $prompt_builder;
}
/**
* Whether the admin setting currently allows NSFW retries.
*
* Defaults to on. Kept as a static so callers can short-circuit before
* constructing a retry object when nothing needs retrying anyway.
*
* @return bool
*/
public static function is_enabled(): bool {
return '1' === (string) get_option( self::SETTING_KEY, '1' );
}
/**
* Replace every NSFW-blocked slot in $batch_results with a fresh result
* from a sanitized fallback prompt.
*
* Mutates $batch_results and $captions in place. Also updates the
* per-slot entry in $captions so the caller writes the fallback
* caption under the post if the retry succeeds.
*
* Side effects: up to one HTTP call per blocked slot; Logger lines
* for every retry attempt (info on success, warning on second block).
*
* @param int $post_id Target post ID (for logs).
* @param array $batch_requests Original per-slot request map.
* @param array<string, array|null> $batch_results Provider result map (mutated).
* @param array<string, string> $captions Caption map (mutated).
* @param array<string, mixed> $article_data Article data (for title).
* @param array<string, mixed>|null $source_data Source data (for title on image_b).
* @return void
*/
public function retry_blocked_slots(
int $post_id,
array $batch_requests,
array &$batch_results,
array &$captions,
array $article_data,
?array $source_data
): void {
foreach ( $batch_results as $key => $entry ) {
if ( ! is_array( $entry ) || 'nsfw_blocked' !== ( $entry['error_type'] ?? '' ) ) {
continue;
}
$title = 'image_b' === $key
? (string) ( $source_data['title'] ?? '' )
: (string) ( $article_data['post_title'] ?? $article_data['suggested_title'] ?? '' );
$fallback = $this->prompt_builder->build_fallback_prompt( $title );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'NSFW retry for post %d slot "%s" with sanitized fallback prompt.', $post_id, $key ),
'image_pipeline'
);
$retry_request = [
'prompt' => $fallback['prompt'],
'width' => (int) ( $batch_requests[ $key ]['width'] ?? 0 ),
'height' => (int) ( $batch_requests[ $key ]['height'] ?? 0 ),
'options' => $batch_requests[ $key ]['options'] ?? [],
];
$retry_results = $this->provider->generate_image_batch( [ $key => $retry_request ] );
$retry_entry = $retry_results[ $key ] ?? [ 'error' => 'Retry provider returned no result.' ];
if ( isset( $retry_entry['error'] ) ) {
PRAutoBlogger_Logger::instance()->warning(
sprintf(
'NSFW retry for post %d slot "%s" also failed: %s',
$post_id,
$key,
(string) $retry_entry['error']
),
'image_pipeline'
);
// Leave the original ['error' => ...] entry so the pipeline
// publishes without this image, matching current behaviour
// for any unrecoverable image failure.
continue;
}
$batch_results[ $key ] = $retry_entry;
$captions[ $key ] = $fallback['caption'];
}
}
}

View File

@@ -4,295 +4,273 @@ declare(strict_types=1);
/**
* Orchestrates image generation for published articles.
*
* Generates two images per article (A/B): Image A (article-driven) as featured
* image, Image B (source-driven) in post meta. Each is independently fallible.
* Generates two images per article (A/B experiment):
* - Image A: Article-driven prompt → featured image
* - Image B: Source-driven prompt → post meta (_prautoblogger_image_b_id)
*
* Each image is independently fallible — if A fails, still try B. If both fail,
* the article is published without images (graceful degradation).
*
* Triggered by: PRAutoBlogger_Pipeline_Runner after publisher creates the post.
* Dependencies: Image provider, Prompt builder, Sideloader, Cost tracker, Logger.
* Dependencies: PRAutoBlogger_Image_Provider_Interface (concrete provider),
* PRAutoBlogger_Image_Prompt_Builder, PRAutoBlogger_Image_Media_Sideloader,
* PRAutoBlogger_Cost_Tracker, PRAutoBlogger_Logger.
*
* @see core/class-pipeline-runner.php — Calls this after publisher.
* @see core/class-image-prompt-builder.php — Prompt generation (scene + caption).
* @see core/class-publisher.php — Sets featured image + post meta.
* @see core/class-image-prompt-builder.php — Prompt generation.
* @see core/class-image-media-sideloader.php — Media library integration.
* @see ARCHITECTURE.md — Image pipeline data flow.
*/
class PRAutoBlogger_Image_Pipeline {
/**
* Default image dimensions (landscape). 1200×632 is the closest
* 8-aligned pair to the standard OG image size (1200×630). Some
* providers require dimensions divisible by 8.
* Default image dimensions (landscape).
*/
private const DEFAULT_WIDTH = 1200;
private const DEFAULT_HEIGHT = 632;
private const DEFAULT_HEIGHT = 630;
/** @var PRAutoBlogger_Image_Provider_Interface Image gen provider. */
/**
* The image provider (FLUX.1 via Cloudflare or another).
*
* @var PRAutoBlogger_Image_Provider_Interface
*/
private PRAutoBlogger_Image_Provider_Interface $provider;
/** @var PRAutoBlogger_Image_Prompt_Builder Builds scene + caption from article data. */
/**
* Prompt builder.
*
* @var PRAutoBlogger_Image_Prompt_Builder
*/
private PRAutoBlogger_Image_Prompt_Builder $prompt_builder;
/** @var PRAutoBlogger_Image_Media_Sideloader Downloads images into WP media library. */
/**
* Media library sideloader.
*
* @var PRAutoBlogger_Image_Media_Sideloader
*/
private PRAutoBlogger_Image_Media_Sideloader $sideloader;
/** @var PRAutoBlogger_Cost_Tracker Logs image generation spend. */
/**
* Cost tracker for logging spend.
*
* @var PRAutoBlogger_Cost_Tracker
*/
private PRAutoBlogger_Cost_Tracker $cost_tracker;
/**
* Construct with dependencies.
*
* When no provider is injected, the constructor reads the
* `prautoblogger_image_provider` setting and instantiates the
* matching concrete class ('runware' or 'openrouter').
*
* @param PRAutoBlogger_Image_Provider_Interface|null $provider Optional provider override.
* @param PRAutoBlogger_Image_Provider_Interface|null $provider Optional provider (defaults to Cloudflare).
* @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker.
*/
public function __construct(
?PRAutoBlogger_Image_Provider_Interface $provider = null,
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
) {
$this->provider = $provider ?? self::create_default_provider();
$this->provider = $provider ?? new PRAutoBlogger_Cloudflare_Image_Provider();
$this->prompt_builder = new PRAutoBlogger_Image_Prompt_Builder();
$this->sideloader = new PRAutoBlogger_Image_Media_Sideloader();
$this->cost_tracker = $cost_tracker ?? new PRAutoBlogger_Cost_Tracker();
}
/**
* Instantiate the image provider based on the admin setting. Defaults
* to Runware on unrecognised values (CF was removed in v0.10.0).
*
* @return PRAutoBlogger_Image_Provider_Interface
*/
private static function create_default_provider(): PRAutoBlogger_Image_Provider_Interface {
$provider_id = (string) get_option( 'prautoblogger_image_provider', PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER );
if ( 'openrouter' === $provider_id ) {
return new PRAutoBlogger_OpenRouter_Image_Provider();
}
return new PRAutoBlogger_Runware_Image_Provider();
}
/**
* Generate and attach images to a published post.
*
* Builds all prompts upfront, fires a single batch call (parallel via
* curl_multi on OpenRouter), then processes results. Wall-clock time
* equals the slowest image, not the sum — solving the Image B timeout.
* Generates Image A (article-driven) and Image B (source-driven). If enabled
* in settings. Each image is independently fallible.
*
* @param int $post_id Post ID to attach images to.
* @param array $article_data Article title + content.
* @param array|null $source_data Optional source data for Image B.
* @param int $post_id Post ID to attach images to.
* @param array{
* post_title?: string,
* post_content?: string,
* suggested_title?: string,
* } $article_data Article title + content.
* @param array{
* title?: string,
* selftext?: string,
* comments?: string[],
* }|null $source_data Optional Reddit source data for Image B.
*
* @return array{image_a_id?: int, image_b_id?: int, cost_usd: float, errors: string[]}
* @return array{
* image_a_id?: int,
* image_b_id?: int,
* cost_usd: float,
* errors: string[],
* } Attachment IDs (if successful) and cost/errors.
*/
public function generate_and_attach_images(
int $post_id,
array $article_data,
?array $source_data = null
): array {
$result = [ 'cost_usd' => 0.0, 'errors' => [] ];
$result = [
'cost_usd' => 0.0,
'errors' => [],
];
// Early exit if image generation is disabled.
if ( ! get_option( 'prautoblogger_image_enabled' ) ) {
PRAutoBlogger_Logger::instance()->info( 'Image generation disabled in settings.', 'image_pipeline' );
return $result;
}
// Check whether Image B is enabled in settings.
$image_b_enabled = get_option( 'prautoblogger_image_b_enabled', '1' );
$has_source_data = null !== $source_data && ! empty( $source_data );
$generate_b = $has_source_data && '1' === $image_b_enabled;
// Budget pre-check: estimate cost for all images before any HTTP calls.
$image_count = $generate_b ? 2 : 1;
$estimated_cost = $this->provider->estimate_cost( self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT ) * $image_count;
if ( $this->cost_tracker->would_exceed_budget( $estimated_cost ) ) {
$result['errors'][] = 'Image generation would exceed monthly budget.';
return $result;
// Generate Image A (article-driven prompt).
$image_a_result = $this->generate_image_a( $post_id, $article_data );
if ( ! is_wp_error( $image_a_result ) && isset( $image_a_result['attachment_id'] ) ) {
$result['image_a_id'] = $image_a_result['attachment_id'];
} else {
$result['errors'][] = is_wp_error( $image_a_result )
? $image_a_result->get_error_message()
: 'Image A generation produced no attachment ID.';
}
$result['cost_usd'] += $image_a_result['cost_usd'] ?? 0.0;
// Build all prompts upfront (LLM calls happen here, sequentially).
$article_prompt = $this->prompt_builder->build_article_prompt( $article_data );
$batch_requests = [
'image_a' => [
'prompt' => $article_prompt['prompt'],
'width' => self::DEFAULT_WIDTH,
'height' => self::DEFAULT_HEIGHT,
],
];
$captions = [ 'image_a' => $article_prompt['caption'] ];
$source_prompt = null;
if ( $generate_b ) {
$source_prompt = $this->prompt_builder->build_source_prompt( $source_data );
$batch_requests['image_b'] = [
'prompt' => $source_prompt['prompt'],
'width' => self::DEFAULT_WIDTH,
'height' => self::DEFAULT_HEIGHT,
];
$captions['image_b'] = $source_prompt['caption'];
}
// Fire all image generation requests in parallel.
try {
$batch_results = $this->provider->generate_image_batch( $batch_requests );
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error( 'Batch generation failed: ' . $e->getMessage(), 'image_pipeline' );
$result['errors'][] = $e->getMessage();
return $result;
}
// Single-shot retry for any slot the provider flagged as NSFW-blocked.
if ( PRAutoBlogger_Image_NSFW_Retry::is_enabled() ) {
( new PRAutoBlogger_Image_NSFW_Retry( $this->provider, $this->prompt_builder ) )
->retry_blocked_slots( $post_id, $batch_requests, $batch_results, $captions, $article_data, $source_data );
}
// Process Image A result.
$this->process_image_a( $post_id, $batch_results, $captions, $result );
// Process Image B result (if requested).
if ( isset( $batch_results['image_b'] ) ) {
$this->process_image_b( $post_id, $batch_results, $captions, $result );
} elseif ( null === $source_data || empty( $source_data ) ) {
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Image B skipped for post %d: no source data.', $post_id ),
'image_pipeline'
);
// Generate Image B (source-driven prompt) if source data is available.
if ( null !== $source_data && ! empty( $source_data ) ) {
$image_b_result = $this->generate_image_b( $post_id, $source_data );
if ( ! is_wp_error( $image_b_result ) && isset( $image_b_result['attachment_id'] ) ) {
$result['image_b_id'] = $image_b_result['attachment_id'];
} else {
$result['errors'][] = is_wp_error( $image_b_result )
? $image_b_result->get_error_message()
: 'Image B generation produced no attachment ID.';
}
$result['cost_usd'] += $image_b_result['cost_usd'] ?? 0.0;
}
return $result;
}
/**
* Process Image A batch result: sideload, set featured image, insert caption.
* Generate Image A from article content.
*
* @param int $post_id Post ID.
* @param array $batch_results Keyed results from generate_image_batch().
* @param array $captions Keyed captions from prompt builder.
* @param array $result Pipeline result array (modified by reference).
* @param int $post_id Post ID.
* @param array $article_data Article data.
*
* @return array{
* attachment_id?: int,
* cost_usd: float,
* }|\WP_Error
*/
private function process_image_a( int $post_id, array $batch_results, array $captions, array &$result ): void {
$image_data = $batch_results['image_a'] ?? null;
if ( ! $image_data || isset( $image_data['error'] ) ) {
$err_msg = $image_data['error'] ?? 'Image A missing from batch results.';
$result['errors'][] = $err_msg;
// Log at WARNING so the path is visible even if the outer catch in the
// caller doesn't fire. See thread image-mime-bug Issue 2.
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'Image A generation failed for post %d: %s', $post_id, $err_msg ),
private function generate_image_a( int $post_id, array $article_data ): array | \WP_Error {
try {
// Build the article-driven prompt.
$prompt = $this->prompt_builder->build_article_prompt( $article_data );
// Estimate cost before generating.
$estimated_cost = $this->provider->estimate_cost( self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT );
if ( $this->cost_tracker->would_exceed_budget( $estimated_cost ) ) {
return new \WP_Error(
'budget_exceeded',
'Image generation would exceed monthly budget.'
);
}
// Generate the image.
$image_data = $this->provider->generate_image( $prompt, self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT );
// Sideload the image into media library.
$attachment_id = $this->sideloader->sideload_image(
$image_data,
$post_id,
substr( $prompt, 0, 100 ) // Use first 100 chars of prompt as alt text.
);
if ( is_wp_error( $attachment_id ) ) {
return $attachment_id;
}
// Log the cost.
$this->cost_tracker->log_image_generation(
$image_data['cost_usd'],
'flux-1-schnell',
$post_id,
'image_a'
);
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Image A generated for post %d (attachment %d, cost $%.4f)', $post_id, $attachment_id, $image_data['cost_usd'] ),
'image_pipeline'
);
return;
}
$attachment_id = $this->sideload_and_log( $image_data, $post_id, 'image_a', 'Image A' );
if ( is_wp_error( $attachment_id ) ) {
$result['errors'][] = $attachment_id->get_error_message();
return;
}
$result['image_a_id'] = $attachment_id;
$result['cost_usd'] += $image_data['cost_usd'];
set_post_thumbnail( $post_id, $attachment_id );
if ( '' !== ( $captions['image_a'] ?? '' ) ) {
update_post_meta( $attachment_id, '_prautoblogger_image_caption', $captions['image_a'] );
$this->prepend_caption_to_post( $post_id, $attachment_id, $captions['image_a'] );
}
}
/**
* Process Image B batch result: sideload, store meta.
*
* @param int $post_id Post ID.
* @param array $batch_results Keyed results from generate_image_batch().
* @param array $captions Keyed captions from prompt builder.
* @param array $result Pipeline result array (modified by reference).
*/
private function process_image_b( int $post_id, array $batch_results, array $captions, array &$result ): void {
$image_data = $batch_results['image_b'] ?? null;
if ( ! $image_data || isset( $image_data['error'] ) ) {
$err_msg = $image_data['error'] ?? 'Image B missing from batch results.';
$result['errors'][] = $err_msg;
// Log at WARNING so the path is visible even if the outer catch in the
// caller doesn't fire. See thread image-mime-bug Issue 2.
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'Image B generation failed for post %d: %s', $post_id, $err_msg ),
return [
'attachment_id' => $attachment_id,
'cost_usd' => $image_data['cost_usd'],
];
} catch ( \Exception $e ) {
PRAutoBlogger_Logger::instance()->error(
'Image A generation failed: ' . $e->getMessage(),
'image_pipeline'
);
return;
}
$attachment_id = $this->sideload_and_log( $image_data, $post_id, 'image_b', 'Image B' );
if ( is_wp_error( $attachment_id ) ) {
$result['errors'][] = $attachment_id->get_error_message();
return;
}
$result['image_b_id'] = $attachment_id;
$result['cost_usd'] += $image_data['cost_usd'];
update_post_meta( $post_id, '_prautoblogger_image_b_id', $attachment_id );
if ( '' !== ( $captions['image_b'] ?? '' ) ) {
update_post_meta( $attachment_id, '_prautoblogger_image_caption', $captions['image_b'] );
return new \WP_Error( 'image_generation_failed', $e->getMessage() );
}
}
/**
* Sideload image bytes into the media library and log the cost.
* Generate Image B from source data.
*
* @param array $image_data Provider result with bytes, model, cost_usd.
* @param int $post_id Post ID.
* @param string $slot Cost-tracking slot ('image_a' or 'image_b').
* @param string $label Human-readable label for logs.
* @return int|\WP_Error Attachment ID on success.
* @param int $post_id Post ID.
* @param array $source_data Source Reddit data.
*
* @return array{
* attachment_id?: int,
* cost_usd: float,
* }|\WP_Error
*/
private function sideload_and_log( array $image_data, int $post_id, string $slot, string $label ) {
$attachment_id = $this->sideloader->sideload_image(
$image_data,
$post_id,
substr( $image_data['model'] ?? 'image', 0, 100 )
);
private function generate_image_b( int $post_id, array $source_data ): array | \WP_Error {
try {
// Build the source-driven prompt.
$prompt = $this->prompt_builder->build_source_prompt( $source_data );
if ( is_wp_error( $attachment_id ) ) {
return $attachment_id;
// Estimate cost before generating.
$estimated_cost = $this->provider->estimate_cost( self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT );
if ( $this->cost_tracker->would_exceed_budget( $estimated_cost ) ) {
return new \WP_Error(
'budget_exceeded',
'Image generation would exceed monthly budget.'
);
}
// Generate the image.
$image_data = $this->provider->generate_image( $prompt, self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT );
// Sideload the image into media library.
$attachment_id = $this->sideloader->sideload_image(
$image_data,
$post_id,
substr( $prompt, 0, 100 ) // Use first 100 chars of prompt as alt text.
);
if ( is_wp_error( $attachment_id ) ) {
return $attachment_id;
}
// Log the cost.
$this->cost_tracker->log_image_generation(
$image_data['cost_usd'],
'flux-1-schnell',
$post_id,
'image_b'
);
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Image B generated for post %d (attachment %d, cost $%.4f)', $post_id, $attachment_id, $image_data['cost_usd'] ),
'image_pipeline'
);
return [
'attachment_id' => $attachment_id,
'cost_usd' => $image_data['cost_usd'],
];
} catch ( \Exception $e ) {
PRAutoBlogger_Logger::instance()->error(
'Image B generation failed: ' . $e->getMessage(),
'image_pipeline'
);
return new \WP_Error( 'image_generation_failed', $e->getMessage() );
}
$this->cost_tracker->log_image_generation(
$image_data['cost_usd'],
$image_data['model'] ?? 'unknown',
$post_id,
$slot
);
PRAutoBlogger_Logger::instance()->info(
sprintf( '%s for post %d (att %d, $%.4f)', $label, $post_id, $attachment_id, $image_data['cost_usd'] ),
'image_pipeline'
);
return $attachment_id;
}
/**
* Prepend the comic caption as styled text at the top of the post content.
*
* Only inserts the caption — NOT the image. The theme already displays the
* featured image, so embedding it again would cause a duplicate.
*
* @param int $post_id Post ID.
* @param int $attachment_id Featured image attachment ID (for meta storage only).
* @param string $caption Caption text (the comic punchline).
*/
private function prepend_caption_to_post( int $post_id, int $attachment_id, string $caption ): void {
$post = get_post( $post_id );
if ( ! $post ) {
return;
}
// Caption-only block — the theme handles the featured image display.
$caption_html = sprintf(
'<p class="prautoblogger-comic-caption" style="text-align:center;font-style:italic;color:#555;font-size:1.1em;margin:0 0 2em 0;">— "%s"</p>',
esc_html( $caption )
);
wp_update_post( [
'ID' => $post_id,
'post_content' => $caption_html . "\n\n" . $post->post_content,
] );
}
}

View File

@@ -4,224 +4,77 @@ declare(strict_types=1);
/**
* Builds image prompts from article content or source data.
*
* Uses a cheap LLM (via OpenRouter) to distill article content into concise
* visual scene descriptions optimised for image generation. Falls back to rule-based
* synthesis if the LLM call fails, so image generation never blocks on a
* prompt-rewriting outage.
* Generates concise visual concepts suitable for FLUX.1 image generation,
* extracting key themes from article titles/content and Reddit source data.
*
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation run.
* Dependencies: PRAutoBlogger_OpenRouter_Provider (LLM call),
* PRAutoBlogger_Cost_Tracker (logs prompt-rewrite cost),
* PRAutoBlogger_Logger (diagnostics).
* Dependencies: None — pure data transformation.
*
* @see core/class-image-pipeline.php — Consumes build_article_prompt() and build_source_prompt().
* @see providers/class-open-router-provider.php — LLM provider used for rewriting.
* @see ARCHITECTURE.md — Image generation data flow.
*/
class PRAutoBlogger_Image_Prompt_Builder {
/**
* Default system prompt that teaches the LLM how to write image-gen
* prompts. Exposed as a `public const` so the admin-settings layer can
* use it as the default value for `prautoblogger_image_prompt_instructions`.
* The option, when non-empty, wins at call time (see rewrite_via_llm).
*/
public const REWRITER_SYSTEM_PROMPT = <<<'PROMPT'
You are a comedy writer and single-panel cartoon creator, like Gary Larson (The Far Side) meets science humor.
Given an article title and summary about peptides, supplements, or biohacking, create a SINGLE-PANEL COMIC concept. Output TWO parts separated by a blank line:
SCENE: One sentence describing the visual gag — a funny, absurd, or ironic situation related to the article's topic. Favor CONCRETE, HUMAN-SCALE scenes: people reacting to things, doctors and patients, scientists in a lab, gym bros with peptide vials, partners at a kitchen table, ordinary objects in ordinary rooms. Keep it simple — one clear visual joke, 1-3 characters max.
CAPTION: A short, punchy caption or speech bubble line (under 15 words) that delivers the punchline. The humor should be dry, nerdy, and accessible — the reader should chuckle even if they only half-understand the science.
Rules:
- The joke MUST relate to the article's actual subject matter, not generic science humor
- DO NOT personify molecules, peptides, hormones, proteins, or other chemical structures (no "anthropomorphic molecule", no "cartoon hormone"). Image models render anthropomorphized chemistry as incoherent blobs. If the article is about a substance, show a PERSON reacting to or using the substance; if the article is abstract (a mechanism, a process, a concept), fall back on a human observer reacting to its effect.
- Characters can have simple cartoon faces (round heads, dot eyes, expressive eyebrows)
- Keep the scene physically simple — few objects, clear staging, easy to read at thumbnail size
- The caption should work as a standalone joke even without the image
- No logos, watermarks, or branding
- Output ONLY the scene and caption. No preamble, no explanation.
Example output format:
A muscular gym bro stares, deflated, at a tiny glass vial in his hand while his friend squats a barbell effortlessly in the background.
"They told me the peptide did the work, not the squats."
PROMPT;
/**
* Max tokens for the rewriter response. Comic concepts need more room
* for the scene description plus the caption punchline.
*/
private const REWRITER_MAX_TOKENS = 180;
/**
* OpenRouter provider for LLM calls. Null until first use.
*
* @var PRAutoBlogger_OpenRouter_Provider|null
*/
private ?PRAutoBlogger_OpenRouter_Provider $llm = null;
/**
* Build a visual prompt from finished article content.
*
* Tries LLM rewriting first; falls back to rule-based synthesis on
* failure. Splits scene (for image gen) from caption (HTML below the
* image) and appends the style suffix.
* Extracts the title and first paragraph to synthesize a product-marketing
* angle visual concept suitable for a featured image. Appends the style suffix
* from plugin options.
*
* @param array{post_title?: string, post_content?: string, suggested_title?: string} $article_data
* @return array{prompt: string, caption: string}
* @param array{
* post_title?: string,
* post_content?: string,
* suggested_title?: string,
* } $article_data Article data with title and HTML content.
*
* @return string Concise prompt (under 200 words) with style suffix appended.
*/
public function build_article_prompt( array $article_data ): array {
$title = $article_data['post_title'] ?? $article_data['suggested_title'] ?? 'Product';
$content = $article_data['post_content'] ?? '';
public function build_article_prompt( array $article_data ): string {
$title = $article_data['post_title'] ?? $article_data['suggested_title'] ?? 'Product';
$content = $article_data['post_content'] ?? '';
// Extract first paragraph from HTML content.
$first_para = $this->extract_first_paragraph( $content );
$parsed = $this->rewrite_via_llm( $title, $first_para );
// Build visual concepts from title + first paragraph.
$concepts = $this->synthesize_visual_concepts( $title, $first_para );
// Append style suffix.
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
return [
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
'caption' => $parsed['caption'],
];
return trim( $concepts . ' ' . $style_suffix );
}
/**
* Build a visual prompt from source Reddit thread data. Tries LLM
* rewriting first; falls back to rule-based synthesis on failure.
* Build a visual prompt from source Reddit thread data.
*
* @param array{title?: string, selftext?: string, comments?: string[]} $source_data
* @return array{prompt: string, caption: string}
* Extracts the Reddit thread title and top comments to synthesize an
* editorial-cartoonist angle visual concept. Appends the style suffix
* from plugin options.
*
* @param array{
* title?: string,
* selftext?: string,
* comments?: string[],
* } $source_data Reddit source data with title and comments.
*
* @return string Concise prompt (under 200 words) with style suffix appended.
*/
public function build_source_prompt( array $source_data ): array {
public function build_source_prompt( array $source_data ): string {
$title = $source_data['title'] ?? 'Reddit Discussion';
$comments = $source_data['comments'] ?? [];
$context = is_array( $comments ) && ! empty( $comments ) ? $comments[0] : '';
$parsed = $this->rewrite_via_llm( $title, $context );
// Build visual concepts from title + top comments.
$concepts = $this->synthesize_visual_concepts(
$title,
is_array( $comments ) && ! empty( $comments ) ? $comments[0] : ''
);
// Append style suffix.
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
return [
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
'caption' => $parsed['caption'],
];
}
/**
* Use a cheap LLM to distill title + context into a visual scene + caption.
*
* The LLM response contains a SCENE line and a CAPTION line separated by a
* blank line. This method parses them apart so the scene drives image gen
* (no text baked in) and the caption is inserted as HTML below the image.
*
* Falls back to rule-based synthesis if the LLM call fails for any
* reason (network, auth, timeout, unexpected response shape).
*
* Side effects: one OpenRouter API call; one cost-tracker log entry.
*
* @param string $title Article or thread title.
* @param string $context First paragraph or top comment.
* @return array{scene: string, caption: string} Scene for image gen, caption for HTML.
*/
private function rewrite_via_llm( string $title, string $context ): array {
$title = trim( sanitize_text_field( $title ) );
$context = trim( sanitize_text_field( $context ) );
// Truncate context to keep prompt tokens low.
if ( strlen( $context ) > 300 ) {
$context = substr( $context, 0, 300 ) . '...';
}
$user_message = "Article title: {$title}";
if ( '' !== $context ) {
$user_message .= "\n\nSummary: {$context}";
}
try {
$llm = $this->get_llm_provider();
$model = get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
$system = $this->resolve_system_prompt();
$result = $llm->send_chat_completion(
[
[ 'role' => 'system', 'content' => $system ],
[ 'role' => 'user', 'content' => $user_message ],
],
$model,
[
'temperature' => 0.7,
'max_tokens' => self::REWRITER_MAX_TOKENS,
]
);
$raw = trim( $result['content'] ?? '' );
// Log the rewrite cost so it shows in the analytics dashboard.
( new PRAutoBlogger_Cost_Tracker() )->log_api_call(
null,
'image_prompt_rewrite',
'openrouter',
$model,
$result['prompt_tokens'] ?? 0,
$result['completion_tokens'] ?? 0
);
$cost = $llm->estimate_cost(
$model,
$result['prompt_tokens'] ?? 0,
$result['completion_tokens'] ?? 0
);
PRAutoBlogger_Logger::instance()->debug(
sprintf( 'Image prompt rewritten (%d→%d chars, $%.6f): %s', strlen( $user_message ), strlen( $raw ), $cost, substr( $raw, 0, 120 ) ),
'image_prompt_builder'
);
if ( '' !== $raw ) {
return $this->parse_scene_and_caption( $raw );
}
} catch ( \Throwable $e ) {
// LLM failure is not fatal — fall back to rule-based synthesis.
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'Image prompt LLM rewrite %s, using fallback: %s', get_class( $e ), $e->getMessage() ),
'image_prompt_builder'
);
}
return $this->synthesize_visual_concepts_fallback( $title, $context );
}
/**
* Parse the LLM response into separate scene and caption parts.
*
* Expected format from the LLM:
* Scene description text here.
*
* "Caption punchline here."
*
* Falls back gracefully: if no blank-line separator is found, the entire
* response becomes the scene with an empty caption.
*
* @param string $raw Raw LLM response text.
* @return array{scene: string, caption: string}
*/
private function parse_scene_and_caption( string $raw ): array {
// Split on blank line (the format the system prompt requests).
$parts = preg_split( '/\n\s*\n/', $raw, 2 );
if ( count( $parts ) >= 2 ) {
$scene = trim( $parts[0] );
$caption = trim( $parts[1] );
// Strip surrounding quotes from caption — the LLM wraps it in quotes.
$caption = trim( $caption, '"\'' );
return [ 'scene' => $scene, 'caption' => $caption ];
}
// No blank-line separator found — treat entire response as scene.
return [ 'scene' => trim( $raw ), 'caption' => '' ];
return trim( $concepts . ' ' . $style_suffix );
}
/**
@@ -231,13 +84,18 @@ PROMPT;
* or 200 characters, whichever comes first.
*
* @param string $html HTML content.
*
* @return string Plain text of the first paragraph.
*/
private function extract_first_paragraph( string $html ): string {
$text = wp_strip_all_tags( $html );
$paras = preg_split( '/\n\n+/', $text );
$para = isset( $paras[0] ) ? trim( $paras[0] ) : '';
// Remove HTML tags.
$text = wp_strip_all_tags( $html );
// Split on double-newline and take the first paragraph.
$paras = preg_split( '/\n\n+/', $text );
$para = isset( $paras[0] ) ? trim( $paras[0] ) : '';
// Limit to first 200 chars to keep prompt concise.
if ( strlen( $para ) > 200 ) {
$para = substr( $para, 0, 200 ) . '...';
}
@@ -246,55 +104,33 @@ PROMPT;
}
/**
* Public entry into the rule-based fallback, used by NSFW retry to
* rebuild a provider-safe prompt from just the article title. Matches
* the return shape of build_article_prompt() / build_source_prompt().
* Synthesize visual concepts from a title and supporting text.
*
* @param string $title Article or source title.
* @return array{prompt: string, caption: string}
* Combines key words and themes into a short visual narrative that FLUX.1
* can render. This is intentionally rule-based rather than LLM-driven to
* avoid another API call per image generation.
*
* @param string $title Main heading / topic.
* @param string $supporting_text Additional context (first para or comment).
*
* @return string Synthesized visual prompt concept.
*/
public function build_fallback_prompt( string $title ): array {
$parsed = $this->synthesize_visual_concepts_fallback( $title, '' );
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
return [
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
'caption' => $parsed['caption'],
];
}
private function synthesize_visual_concepts( string $title, string $supporting_text ): string {
$title = trim( sanitize_text_field( $title ) );
$text = trim( sanitize_text_field( $supporting_text ) );
/**
* Rule-based fallback when LLM rewriting is unavailable.
*
* Kept deliberately simple — this only runs if OpenRouter is down.
*
* @param string $title Main heading / topic.
* @param string $supporting_text Additional context.
* @return array{scene: string, caption: string} Scene for image gen, caption for HTML.
*/
private function synthesize_visual_concepts_fallback( string $title, string $supporting_text ): array {
// Fallback produces a simple comic concept when the LLM is unavailable.
$scene = sprintf(
'A cartoon scientist in a lab coat looking bewildered while examining something related to: %s.',
$title
);
// Build a simple declarative concept from title and text snippets.
$prompt = "A visual representation of: {$title}";
return [
'scene' => trim( $scene ),
'caption' => 'Science is full of surprises.',
];
}
if ( ! empty( $text ) ) {
// Take first 150 chars to avoid bloating the prompt.
$snippet = strlen( $text ) > 150
? substr( $text, 0, 150 ) . '...'
: $text;
/** Lazy-load the OpenRouter provider. */
private function get_llm_provider(): PRAutoBlogger_OpenRouter_Provider {
if ( null === $this->llm ) {
$this->llm = new PRAutoBlogger_OpenRouter_Provider();
$prompt .= ". Context: {$snippet}";
}
return $this->llm;
}
/** Admin option wins; blank falls back to REWRITER_SYSTEM_PROMPT. */
private function resolve_system_prompt(): string {
$override = (string) get_option( 'prautoblogger_image_prompt_instructions', '' );
return '' !== trim( $override ) ? $override : self::REWRITER_SYSTEM_PROMPT;
return trim( $prompt );
}
}

View File

@@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
/**
* Extracts valid JSON from potentially dirty LLM response strings.
*
* Many models ignore the `response_format: json_object` instruction and wrap
* their output in markdown fences, add preamble text, or append trailing
* commentary. This class strips all of that and returns clean decoded data.
*
* Triggered by: Any class that parses JSON from an LLM response.
* Dependencies: None (pure utility).
*
* @see core/class-content-analyzer.php — Uses this for analysis JSON parsing.
* @see core/class-chief-editor.php — Uses this for editorial review JSON parsing.
*/
class PRAutoBlogger_Json_Extractor {
/**
* Decode JSON from an LLM response, tolerating markdown fences and preamble.
*
* Attempts, in order:
* 1. Direct json_decode (model returned clean JSON).
* 2. Strip ```json ... ``` fences, then decode.
* 3. Find the first `{` and last `}` and decode that substring.
*
* @param string $raw The raw LLM response content.
* @return array<string, mixed>|null Decoded associative array, or null on failure.
*/
public static function decode( string $raw ): ?array {
$trimmed = trim( $raw );
// 1. Clean JSON — best case.
$decoded = json_decode( $trimmed, true );
if ( is_array( $decoded ) ) {
return $decoded;
}
// 2. Markdown-fenced JSON (```json ... ``` or ``` ... ```).
if ( preg_match( '/```(?:json)?\s*\n?(.*?)\n?\s*```/s', $trimmed, $matches ) ) {
$decoded = json_decode( trim( $matches[1] ), true );
if ( is_array( $decoded ) ) {
return $decoded;
}
}
// 3. Brute-force: find outermost { ... } substring.
$first_brace = strpos( $trimmed, '{' );
$last_brace = strrpos( $trimmed, '}' );
if ( false !== $first_brace && false !== $last_brace && $last_brace > $first_brace ) {
$candidate = substr( $trimmed, $first_brace, $last_brace - $first_brace + 1 );
$decoded = json_decode( $candidate, true );
if ( is_array( $decoded ) ) {
return $decoded;
}
}
return null;
}
}

View File

@@ -126,11 +126,6 @@ class PRAutoBlogger_Logger {
}
global $wpdb;
if ( null === $wpdb ) {
// No database available (unit test environment). PHP error_log
// was already called above for warnings/errors; silently skip DB write.
return;
}
$table = $wpdb->prefix . 'prautoblogger_event_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery

View File

@@ -1,149 +0,0 @@
<?php
declare(strict_types=1);
/**
* Deterministic post-processor that hyperlinks peptide mentions in HTML content.
*
* Scans generated article HTML for any peptide name that exists in the PR Core
* database and wraps every mention in an <a> tag pointing to its /peptides/ page.
* Skips text that is already inside an existing <a> tag to avoid nested links.
*
* Triggered by: PRAutoBlogger_Publisher::create_post() (before wp_insert_post).
* Dependencies: PR Core plugin (pr_peptide CPT). Gracefully no-ops if PR Core is inactive.
*
* @see core/class-publisher.php — Calls inject_links() on post_content.
* @see peptide-repo-core/includes/cpt/class-pr-core-peptide-cpt.php — Peptide CPT.
*/
class PRAutoBlogger_Peptide_Linker {
/**
* Inject peptide hyperlinks into HTML content.
*
* Every occurrence of a known peptide name is wrapped in a link to its
* database page, except when the text is already inside an <a> tag.
*
* @param string $html Article HTML content.
* @return string HTML with peptide names hyperlinked.
*/
public static function inject_links( string $html ): string {
if ( ! post_type_exists( 'pr_peptide' ) ) {
return $html;
}
$link_map = self::build_link_map();
if ( empty( $link_map ) ) {
return $html;
}
// Build a single regex alternation of all peptide names, longest first
// so "BPC-157" matches before a hypothetical "BPC" would.
$names = array_keys( $link_map );
usort( $names, static function ( string $a, string $b ): int {
return strlen( $b ) - strlen( $a );
} );
$escaped = array_map( static function ( string $name ): string {
return preg_quote( $name, '/' );
}, $names );
// Word-boundary match, case-insensitive.
$pattern = '/\b(' . implode( '|', $escaped ) . ')\b/iu';
// Split HTML into segments: inside <a>…</a> tags vs. outside.
// Only replace in segments that are NOT inside an anchor tag.
return self::replace_outside_links( $html, $pattern, $link_map );
}
/**
* Build a map of peptide display names → permalink URLs.
*
* Includes the canonical title and common aliases (e.g., "BPC-157" and
* "BPC 157") so both hyphenated and space-separated forms get linked.
*
* @return array<string, string> Name → URL pairs, case-preserved keys.
*/
private static function build_link_map(): array {
$peptides = get_posts( [
'post_type' => 'pr_peptide',
'post_status' => 'publish',
'posts_per_page' => 200,
'orderby' => 'title',
'order' => 'ASC',
'fields' => 'ids',
] );
$map = [];
foreach ( $peptides as $peptide_id ) {
$title = get_the_title( $peptide_id );
$url = get_permalink( $peptide_id );
if ( ! $title || ! $url ) {
continue;
}
// Canonical name (e.g., "BPC-157").
$map[ $title ] = $url;
// If the name contains hyphens, also match the space-separated
// variant (e.g., "BPC 157" → same URL as "BPC-157").
if ( strpos( $title, '-' ) !== false ) {
$space_variant = str_replace( '-', ' ', $title );
$map[ $space_variant ] = $url;
}
}
return $map;
}
/**
* Replace peptide names with links, but only outside existing <a> tags.
*
* Splits HTML into chunks by <a …>…</a> boundaries. Replacement only
* runs on chunks that are outside anchors, preventing nested <a> tags.
*
* @param string $html Full HTML content.
* @param string $pattern Regex pattern matching peptide names.
* @param array<string,string> $link_map Name → URL map (case-preserved keys).
* @return string HTML with replacements applied.
*/
private static function replace_outside_links( string $html, string $pattern, array $link_map ): string {
// Split on anchor tags, keeping the delimiters.
// This regex captures full <a …>…</a> blocks as array elements.
$parts = preg_split( '/(<a\s[^>]*>.*?<\/a>)/is', $html, -1, PREG_SPLIT_DELIM_CAPTURE );
if ( false === $parts ) {
return $html;
}
// Build a lowercase → URL lookup for case-insensitive matching.
$lower_map = [];
foreach ( $link_map as $name => $url ) {
$lower_map[ mb_strtolower( $name ) ] = $url;
}
$result = '';
foreach ( $parts as $part ) {
// If this chunk is an anchor tag, pass it through untouched.
if ( preg_match( '/^<a\s/i', $part ) ) {
$result .= $part;
continue;
}
// Replace peptide names with hyperlinks.
$result .= preg_replace_callback(
$pattern,
static function ( array $matches ) use ( $lower_map ): string {
$matched_text = $matches[0];
$key = mb_strtolower( $matched_text );
$url = $lower_map[ $key ] ?? '';
if ( '' === $url ) {
return $matched_text;
}
return '<a href="' . esc_url( $url ) . '">' . esc_html( $matched_text ) . '</a>';
},
$part
);
}
return $result;
}
}

View File

@@ -2,37 +2,41 @@
declare(strict_types=1);
/**
* Executes the content generation pipeline using a chained-job architecture.
* Executes the full content generation pipeline.
*
* Orchestrates: Budget check → Collect source data → Analyze patterns →
* Score ideas → Generate article 1 in-process → Queue articles 2..N as
* individual WP-Cron events so each gets its own PHP execution time budget.
* Score ideas → Generate content → Editorial review → Publish.
*
* Why chained jobs: Hostinger kills PHP processes at ~120s. A single article
* (LLM generation + editorial + 2 images) takes ~60-90s, so one-per-process
* is safe. Running N articles sequentially in one process times out at N≥2.
* Triggered by: PRAutoBlogger::on_daily_generation() and PRAutoBlogger::on_ajax_generate_now().
* Dependencies: All core content classes (Collector, Analyzer, Scorer, Generator, Editor, Publisher).
*
* Triggered by: PRAutoBlogger_Executor (daily cron + manual "Generate Now").
* Dependencies: Core content classes, Article_Worker, WP-Cron for chaining.
*
* @see class-executor.php — Cron/AJAX handlers that call us.
* @see core/class-article-worker.php — Single-article generation worker.
* @see core/class-cost-tracker.php — Tracks costs throughout the run.
* @see ARCHITECTURE.md — Data flow diagram.
* @see class-prautoblogger.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 PRAutoBlogger_Pipeline_Runner {
/** Option key for the article generation queue. */
private const QUEUE_KEY = 'prautoblogger_article_queue';
/** WP-Cron action for chained article generation. */
public const CRON_ACTION = 'prautoblogger_generate_queued_article';
/** @var bool Whether to skip duplicate-topic detection. */
/**
* 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.
*/
/** @var bool Whether to skip duplicate-topic detection (used for manual runs). */
private bool $skip_dedup = false;
/**
* Allow manual runs to skip the has_similar_post() check so "Generate Now"
* always produces content even when a related article was recently published.
*
* @param bool $skip True to skip deduplication.
*
* @return $this
*/
public function set_skip_dedup( bool $skip ): self {
@@ -40,245 +44,104 @@ class PRAutoBlogger_Pipeline_Runner {
return $this;
}
/**
* Run orchestration and generate the first article in-process.
*
* Steps: Budget check → Collect → Analyze → Score → Generate article 1.
* Remaining articles are queued as chained WP-Cron events.
*
* Side effects: API calls, DB writes, post creation, cron scheduling.
*
* @return array{generated: int, published: int, rejected: int, cost: float}
* @throws \RuntimeException If budget exceeded.
*/
public function run(): array {
$cost_tracker = new PRAutoBlogger_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.', 'prautoblogger' )
);
}
$ideas = $this->orchestrate( $cost_tracker );
$target_count = absint( get_option( 'prautoblogger_daily_article_target', 1 ) );
// Step 1: Collect source data.
$collector = new PRAutoBlogger_Source_Collector();
$collector->collect_from_all_sources();
// Step 2: Analyze collected data for patterns.
// Pass a generous target so the LLM returns enough diverse ideas for
// scoring to pick from — ask for 2× the daily target so dedup has room.
$llm = new PRAutoBlogger_OpenRouter_Provider();
$analyzer = new PRAutoBlogger_Content_Analyzer( $llm, $cost_tracker );
$analysis = $analyzer->analyze_recent_data( max( $target_count * 2, 6 ) );
// Step 3: Score and deduplicate ideas.
$scorer = new PRAutoBlogger_Idea_Scorer();
$scorer->set_skip_dedup( $this->skip_dedup );
$ideas = $scorer->score_and_rank( $analysis, $target_count );
if ( empty( $ideas ) ) {
PRAutoBlogger_Logger::instance()->info( 'No viable article ideas found. Skipping generation.', 'pipeline' );
return [ 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ];
}
// Generate article 1 in this process — always fits within time limit.
$first_idea = array_shift( $ideas );
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
$result = $worker->generate( $first_idea );
// Step 4 & 5: Generate content and run through editor for each idea.
$generator = new PRAutoBlogger_Content_Generator( $llm, $cost_tracker );
$editor = new PRAutoBlogger_Chief_Editor( $llm, $cost_tracker );
$publisher = new PRAutoBlogger_Publisher();
$auto_publish = in_array( get_option( 'prautoblogger_auto_publish', '0' ), [ '1', 'yes' ], true );
// Queue remaining articles as chained cron events.
if ( ! empty( $ideas ) ) {
$this->queue_remaining( $ideas, $cost_tracker->get_run_id(), $result );
} else {
// Single-article run — amortize research costs now (1 article = full cost).
PRAutoBlogger_Post_Assembler::amortize_research_costs( $cost_tracker->get_run_id() );
$this->log_summary( $result );
$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() ) {
PRAutoBlogger_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++;
PRAutoBlogger_Logger::instance()->info( 'Article rejected by editor: ' . $idea->get_topic(), 'pipeline' );
}
} catch ( \Exception $e ) {
PRAutoBlogger_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.
}
}
return $result;
}
$total_cost = $cost_tracker->get_current_run_cost();
/**
* Process the next article from the persistent queue.
*
* Called by WP-Cron for articles 2, 3, …N. Pops one idea, generates
* it, updates running totals, and chains the next job or finalizes.
*
* Side effects: API calls, DB writes, post creation, cron scheduling.
*/
public function process_next_queued_article(): void {
$queue = get_option( self::QUEUE_KEY );
if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) {
$this->finish_queue();
return;
}
$idea_data = array_shift( $queue['ideas'] );
$idea = new PRAutoBlogger_Article_Idea( $idea_data );
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
$cost_tracker->set_run_id( $queue['run_id'] );
if ( $cost_tracker->is_budget_exceeded() ) {
PRAutoBlogger_Logger::instance()->warning(
'Budget limit reached. Aborting remaining queued articles.',
'pipeline'
);
$this->finish_queue();
return;
}
// Persist the consumed queue BEFORE generating so the status poller
// cannot re-schedule a cron event for the same idea. Without this,
// the DB still holds the old queue during the ~90s generation window,
// and the poller's orphan-recovery logic duplicates the last article.
if ( ! empty( $queue['ideas'] ) ) {
update_option( self::QUEUE_KEY, $queue, false );
} else {
delete_option( self::QUEUE_KEY );
}
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
$article_result = $worker->generate( $idea );
// Merge into running totals.
$queue['results']['generated'] += $article_result['generated'];
$queue['results']['published'] += $article_result['published'];
$queue['results']['rejected'] += $article_result['rejected'];
$queue['results']['cost'] += $article_result['cost'];
if ( ! empty( $queue['ideas'] ) ) {
update_option( self::QUEUE_KEY, $queue, false );
$this->update_queue_status( $queue );
$this->schedule_next();
} else {
// All articles done — amortize shared research costs across them.
PRAutoBlogger_Post_Assembler::amortize_research_costs( $queue['run_id'] );
$this->write_final_status( $queue['results'] );
delete_option( self::QUEUE_KEY );
PRAutoBlogger_Generation_Lock::release();
$this->log_summary( $queue['results'] );
}
}
// ── Private helpers ─────────────────────────────────────────────────
/**
* Collect → Analyze → Score. Returns ranked ideas.
*
* @param PRAutoBlogger_Cost_Tracker $cost_tracker
* @return PRAutoBlogger_Article_Idea[]
*/
private function orchestrate( PRAutoBlogger_Cost_Tracker $cost_tracker ): array {
$target = absint( get_option( 'prautoblogger_daily_article_target', 1 ) );
$enabled = json_decode( get_option( 'prautoblogger_enabled_sources', '["reddit"]' ), true );
$source_labels = is_array( $enabled ) ? implode( ', ', $enabled ) : 'reddit';
/* translators: %s is a comma-separated list of enabled source names, e.g. "reddit, llm_research". */
$this->broadcast_stage( sprintf( __( 'Collecting sources from %s…', 'prautoblogger' ), $source_labels ) );
( new PRAutoBlogger_Source_Collector() )
->set_cost_tracker( $cost_tracker )
->collect_from_all_sources();
$this->broadcast_stage( __( 'Analyzing topics and scoring…', 'prautoblogger' ) );
$llm = new PRAutoBlogger_OpenRouter_Provider();
$analyzer = new PRAutoBlogger_Content_Analyzer( $llm, $cost_tracker );
$analysis = $analyzer->analyze_recent_data( max( $target * 2, 6 ) );
$this->broadcast_stage( __( 'Selecting best topic…', 'prautoblogger' ) );
$scorer = new PRAutoBlogger_Idea_Scorer();
$scorer->set_skip_dedup( $this->skip_dedup );
$ideas = $scorer->score_and_rank( $analysis, $target );
if ( empty( $ideas ) ) {
PRAutoBlogger_Logger::instance()->info(
'No viable article ideas found. Skipping generation.',
'pipeline'
);
}
return $ideas;
}
/**
* Store remaining ideas and schedule the next chained job.
*
* @param PRAutoBlogger_Article_Idea[] $ideas Remaining ideas (2..N).
* @param string $run_id Pipeline run UUID.
* @param array $first Results from article 1.
*/
private function queue_remaining( array $ideas, string $run_id, array $first ): void {
$serialized = array_map(
static fn( PRAutoBlogger_Article_Idea $i ) => $i->to_array(),
$ideas
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Pipeline complete: %d generated, %d published, %d rejected. Cost: $%.4f', $generated, $published, $rejected, $total_cost ),
'pipeline'
);
$queue = [
'run_id' => $run_id,
'ideas' => $serialized,
'results' => $first,
return [
'generated' => $generated,
'published' => $published,
'rejected' => $rejected,
'cost' => $total_cost,
];
update_option( self::QUEUE_KEY, $queue, false );
$this->update_queue_status( $queue );
$this->schedule_next();
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Article 1 done. Queued %d more as chained jobs.', count( $serialized ) ),
'pipeline'
);
}
/** Schedule the next chained cron event and fire it immediately. */
private function schedule_next(): void {
if ( ! wp_next_scheduled( self::CRON_ACTION ) ) {
wp_schedule_single_event( time(), self::CRON_ACTION );
}
spawn_cron();
wp_remote_post(
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
[ 'timeout' => 0.01, 'blocking' => false, 'sslverify' => false ]
);
}
/** Update the frontend status transient with queue progress. */
private function update_queue_status( array $queue ): void {
$done = $queue['results']['generated'] + $queue['results']['rejected'];
$total = $done + count( $queue['ideas'] );
$current = get_transient( 'prautoblogger_generation_status' );
set_transient( 'prautoblogger_generation_status', [
'status' => 'running',
'stage' => sprintf( __( 'Generating article %1$d of %2$d…', 'prautoblogger' ), $done + 1, $total ),
'started' => is_array( $current ) ? ( $current['started'] ?? time() ) : time(),
'last_updated' => time(),
], 600 );
}
/** Write the final "complete" status transient. */
private function write_final_status( array $r ): void {
set_transient( 'prautoblogger_generation_status', [
'status' => 'complete',
'generated' => $r['generated'],
'published' => $r['published'],
'rejected' => $r['rejected'],
'cost' => $r['cost'],
], 600 );
}
/** Clean up a stale or empty queue. */
private function finish_queue(): void {
$queue = get_option( self::QUEUE_KEY );
if ( is_array( $queue ) && isset( $queue['results'] ) ) {
$this->write_final_status( $queue['results'] );
}
delete_option( self::QUEUE_KEY );
PRAutoBlogger_Generation_Lock::release();
}
/** Log the pipeline summary. */
private function log_summary( array $r ): void {
PRAutoBlogger_Logger::instance()->info(
sprintf(
'Pipeline complete: %d generated, %d published, %d rejected. Cost: $%.4f',
$r['generated'], $r['published'], $r['rejected'], $r['cost']
),
'pipeline'
);
}
/** Update the generation status transient with the current stage. */
private function broadcast_stage( string $stage ): void {
$key = 'prautoblogger_generation_status';
$current = get_transient( $key );
if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) {
$current['stage'] = $stage;
$current['last_updated'] = time();
set_transient( $key, $current, 600 );
}
}
}

View File

@@ -1,262 +0,0 @@
<?php
declare(strict_types=1);
/**
* Post-creation assembly helpers: taxonomy, log linking, images, content sanitization.
*
* What: After wp_insert_post, assigns categories/tags, links generation logs,
* generates and attaches images, and sanitizes raw LLM content.
* Who calls it: PRAutoBlogger_Publisher::create_post().
* Dependencies: WordPress taxonomy/meta APIs, PRAutoBlogger_Image_Pipeline, PRAutoBlogger_Logger.
*
* @see core/class-publisher.php — Orchestrates post creation and calls these helpers.
* @see core/class-image-pipeline.php — Image generation for attach_generated_images.
*/
class PRAutoBlogger_Post_Assembler {
/**
* Assign category and tags to a generated post.
*
* @param int $post_id Post ID.
* @param PRAutoBlogger_Article_Idea $idea Article idea with type and keywords.
*/
public static function assign_taxonomy_terms( int $post_id, PRAutoBlogger_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.
* Uses run_id for precise linking; falls back to timestamp for legacy data.
*
* @param int $post_id Post ID.
* @param string|null $run_id Pipeline run identifier, or null for legacy fallback.
*/
public static function link_generation_logs( int $post_id, ?string $run_id = null ): void {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_generation_log';
if ( null !== $run_id && '' !== $run_id ) {
// Exclude 'llm_research' stage — those shared costs are amortized
// across all articles after the full pipeline completes, not linked
// to a single post. See amortize_research_costs().
// 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 AND stage != 'llm_research'",
$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 )
)
);
}
}
/**
* Amortize shared LLM research costs across all articles in a pipeline run.
*
* The research LLM call runs once per pipeline execution before any articles
* are generated. Its cost is logged with the run_id but no post_id. After all
* articles finish, this method divides the research cost evenly and inserts
* one 'llm_research' row per article so the cost breakdown popover includes
* the amortized research overhead.
*
* Side effects: database SELECT, INSERT, DELETE on generation_log.
*
* @param string|null $run_id Pipeline run UUID. Null or empty is a no-op.
*/
public static function amortize_research_costs( ?string $run_id ): void {
if ( null === $run_id || '' === $run_id ) {
return;
}
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_generation_log';
// Find the unlinked research cost row for this run.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$research_row = $wpdb->get_row(
$wpdb->prepare(
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens
FROM {$table}
WHERE run_id = %s AND stage = 'llm_research' AND post_id IS NULL
LIMIT 1",
$run_id
)
);
if ( ! $research_row || (float) $research_row->estimated_cost <= 0.0 ) {
return; // No research cost to amortize (LLM research not enabled or free).
}
// Count distinct articles produced in this run.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$post_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL",
$run_id
)
);
$post_count = count( $post_ids );
if ( $post_count < 1 ) {
return; // No articles produced — nothing to amortize into.
}
$total_cost = (float) $research_row->estimated_cost;
$amortized_cost = $total_cost / $post_count;
$total_prompt = (int) $research_row->prompt_tokens;
$total_completion = (int) $research_row->completion_tokens;
$amortized_prompt = (int) round( $total_prompt / $post_count );
$amortized_compl = (int) round( $total_completion / $post_count );
// Insert one amortized research row per article.
foreach ( $post_ids as $post_id ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert( $table, [
'post_id' => (int) $post_id,
'run_id' => $run_id,
'stage' => 'llm_research',
'provider' => $research_row->provider,
'model' => $research_row->model,
'prompt_tokens' => $amortized_prompt,
'completion_tokens' => $amortized_compl,
'estimated_cost' => $amortized_cost,
'response_status' => 'success',
'error_message' => '',
'created_at' => current_time( 'mysql' ),
] );
}
// Remove the original unlinked row — it's been replaced by per-article rows.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->delete( $table, [ 'id' => (int) $research_row->id ] );
PRAutoBlogger_Logger::instance()->info(
sprintf(
'Amortized LLM research cost $%.4f across %d articles ($%.4f each).',
$total_cost,
$post_count,
$amortized_cost
),
'cost-tracker'
);
}
/**
* Strip common LLM output artifacts from generated content.
* Removes markdown fences, HTML document wrappers, stray head-level tags.
*
* @param string $content Raw LLM output.
* @return string Cleaned HTML suitable for post_content.
*/
public static function sanitize_llm_content( string $content ): string {
$content = trim( $content );
// Strip markdown code fences: ```html ... ``` or ``` ... ```
if ( preg_match( '/^```(?:\w+)?\s*\n(.*?)```\s*$/s', $content, $matches ) ) {
$content = trim( $matches[1] );
}
// Strip full HTML document wrappers — extract only <body> inner content.
if ( preg_match( '/<body[^>]*>(.*)<\/body>/is', $content, $matches ) ) {
$content = trim( $matches[1] );
}
// Remove stray document-level tags.
$content = preg_replace( '/<\/?(html|head|body|meta|title|!DOCTYPE)[^>]*>/i', '', $content );
$content = preg_replace( '/<link\s[^>]*>/i', '', $content );
$content = preg_replace( '/<style[^>]*>.*?<\/style>/is', '', $content );
$content = preg_replace( '/\n{3,}/', "\n\n", $content );
return trim( $content );
}
/**
* Get the default author ID for generated posts.
*
* @return int WordPress user ID.
*/
public static function get_default_author_id(): int {
$author_id = absint( get_option( 'prautoblogger_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;
}
/**
* Generate and attach images to a published post.
* Non-blocking — logs errors but doesn't re-throw since the post is already created.
*
* @param int $post_id Post ID.
* @param PRAutoBlogger_Article_Idea $idea Article idea (contains source IDs).
* @param array $post_data Post data array (for article content).
*/
public static function attach_generated_images( int $post_id, PRAutoBlogger_Article_Idea $idea, array $post_data ): void {
// Fetch the original Reddit source data for Image B's source-driven prompt.
// The idea's source_ids point to rows in the source_data table collected
// during the pipeline's collection step.
$source_data = ( new PRAutoBlogger_Source_Collector() )->get_source_data_for_image( $idea->get_source_ids() );
try {
// The pipeline now sets featured image and Image B meta
// internally, immediately after each image generates. This
// ensures attachment persists even if the process times out
// before this method returns.
$result = ( new PRAutoBlogger_Image_Pipeline() )->generate_and_attach_images( $post_id, $post_data, $source_data );
if ( ! empty( $result['errors'] ) ) {
foreach ( $result['errors'] as $error ) {
PRAutoBlogger_Logger::instance()->warning( 'Image warning for post ' . $post_id . ': ' . $error, 'publisher' );
}
}
if ( $result['cost_usd'] > 0.0 ) {
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Image generation cost: $%.4f for post %d', $result['cost_usd'], $post_id ),
'publisher'
);
}
} catch ( \Throwable $e ) {
// Catch \Throwable (not just \Exception) so PHP 8 TypeError/Error
// doesn't kill the process silently without any log entry.
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Image pipeline %s for post %d: %s', get_class( $e ), $post_id, $e->getMessage() ),
'publisher'
);
}
}
}

View File

@@ -7,12 +7,9 @@ declare(strict_types=1);
* Handles both publishing (editor-approved) and saving as draft (editor-rejected).
* Stores all generation metadata as post_meta for transparency and auditability.
*
* Triggered by: PRAutoBlogger_Pipeline_Runner (step 6).
* Dependencies: PRAutoBlogger_Post_Assembler (taxonomy, images, sanitization),
* WordPress wp_insert_post(), post meta API.
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 6).
* Dependencies: WordPress wp_insert_post(), post meta API.
*
* @see core/class-post-assembler.php — Post-creation helpers (taxonomy, images, logs).
* @see core/class-peptide-linker.php — Injects peptide database hyperlinks into content.
* @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.
@@ -22,11 +19,18 @@ class PRAutoBlogger_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 PRAutoBlogger_Article_Idea $idea The original article idea.
* @param PRAutoBlogger_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(
@@ -45,7 +49,9 @@ class PRAutoBlogger_Publisher {
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
* @param PRAutoBlogger_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(
@@ -65,7 +71,9 @@ class PRAutoBlogger_Publisher {
* @param PRAutoBlogger_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(
@@ -75,36 +83,47 @@ class PRAutoBlogger_Publisher {
string $post_status,
?string $run_id = null
): int {
// Clean LLM artifacts, then inject peptide hyperlinks deterministically
// before the content enters WordPress. Peptide linker no-ops gracefully
// if PR Core is not active.
$clean_content = PRAutoBlogger_Post_Assembler::sanitize_llm_content( $content );
$linked_content = PRAutoBlogger_Peptide_Linker::inject_links( $clean_content );
$post_data = [
'post_title' => sanitize_text_field( $idea->get_suggested_title() ),
'post_content' => wp_kses_post( $linked_content ),
'post_content' => wp_kses_post( $this->sanitize_llm_content( $content ) ),
'post_status' => $post_status,
'post_type' => 'post',
'post_author' => PRAutoBlogger_Post_Assembler::get_default_author_id(),
'meta_input' => $this->build_meta( $idea, $review, $run_id ),
'post_author' => $this->get_default_author_id(),
'meta_input' => $this->build_meta( $idea, $review ),
];
/** @see class-prautoblogger.php — listeners registered in main loader. */
/**
* Fires before an PRAutoBlogger post is created.
*
* Listeners registered in: class-prautoblogger.php (main loader).
*
* @param array $post_data Post data array.
* @param PRAutoBlogger_Article_Idea $idea The article idea.
* @param PRAutoBlogger_Editorial_Review $review The editorial review.
*/
$post_data = apply_filters( 'prautoblogger_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( __( 'Failed to create post: %s', 'prautoblogger' ), $post_id->get_error_message() )
sprintf(
/* translators: %s: WordPress error message */
__( 'Failed to create post: %s', 'prautoblogger' ),
$post_id->get_error_message()
)
);
}
PRAutoBlogger_Post_Assembler::assign_taxonomy_terms( $post_id, $idea );
PRAutoBlogger_Post_Assembler::link_generation_logs( $post_id, $run_id );
// 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 );
// Generate and attach images if published and images enabled.
if ( 'publish' === $post_status ) {
PRAutoBlogger_Post_Assembler::attach_generated_images( $post_id, $idea, $post_data );
$this->attach_generated_images( $post_id, $idea, $post_data );
}
PRAutoBlogger_Logger::instance()->info(
@@ -112,6 +131,14 @@ class PRAutoBlogger_Publisher {
'publisher'
);
/**
* Fires after an PRAutoBlogger post is successfully created.
*
* @param int $post_id The new post ID.
* @param string $post_status 'publish' or 'draft'.
* @param PRAutoBlogger_Article_Idea $idea The article idea.
* @param PRAutoBlogger_Editorial_Review $review The editorial review.
*/
do_action( 'prautoblogger_post_created', $post_id, $post_status, $idea, $review );
return $post_id;
@@ -120,39 +147,241 @@ class PRAutoBlogger_Publisher {
/**
* Build post_meta array for generation metadata.
*
* The `_prautoblogger_run_id` key (added v0.8.1) lets the orphan-research
* reaper attribute an orphan `llm_research` row back to its sibling
* articles without re-walking the gen_log table. See
* core/class-research-reaper.php.
*
* @param PRAutoBlogger_Article_Idea $idea
* @param PRAutoBlogger_Editorial_Review $review
* @param string|null $run_id Pipeline run UUID, or null in legacy paths.
*
* @return array<string, mixed>
*/
private function build_meta(
PRAutoBlogger_Article_Idea $idea,
PRAutoBlogger_Editorial_Review $review,
?string $run_id = null
PRAutoBlogger_Editorial_Review $review
): array {
$meta = [
'_prautoblogger_generated' => '1',
'_prautoblogger_analysis_id' => $idea->get_analysis_id(),
'_prautoblogger_source_ids' => wp_json_encode( $idea->get_source_ids() ),
'_prautoblogger_model_used' => get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL ),
'_prautoblogger_pipeline_mode' => get_option( 'prautoblogger_writing_pipeline', 'multi_step' ),
'_prautoblogger_editor_verdict' => $review->get_verdict(),
'_prautoblogger_editor_notes' => $review->get_notes(),
'_prautoblogger_quality_score' => $review->get_quality_score(),
'_prautoblogger_seo_score' => $review->get_seo_score(),
'_prautoblogger_generated_at' => gmdate( 'c' ),
'_prautoblogger_topic' => $idea->get_topic(),
'_prautoblogger_article_type' => $idea->get_article_type(),
'_prautoblogger_target_keywords' => wp_json_encode( $idea->get_target_keywords() ),
$pipeline_mode = get_option( 'prautoblogger_writing_pipeline', 'multi_step' );
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
return [
'_prautoblogger_generated' => '1',
'_prautoblogger_analysis_id' => $idea->get_analysis_id(),
'_prautoblogger_source_ids' => wp_json_encode( $idea->get_source_ids() ),
'_prautoblogger_model_used' => $model,
'_prautoblogger_pipeline_mode' => $pipeline_mode,
'_prautoblogger_editor_verdict' => $review->get_verdict(),
'_prautoblogger_editor_notes' => $review->get_notes(),
'_prautoblogger_quality_score' => $review->get_quality_score(),
'_prautoblogger_seo_score' => $review->get_seo_score(),
'_prautoblogger_generated_at' => gmdate( 'c' ),
'_prautoblogger_topic' => $idea->get_topic(),
'_prautoblogger_article_type' => $idea->get_article_type(),
'_prautoblogger_target_keywords' => wp_json_encode( $idea->get_target_keywords() ),
];
if ( null !== $run_id && '' !== $run_id ) {
$meta['_prautoblogger_run_id'] = $run_id;
}
/**
* Assign category and tags to a generated post.
*
* @param int $post_id
* @param PRAutoBlogger_Article_Idea $idea
*
* @return void
*/
private function assign_taxonomy_terms( int $post_id, PRAutoBlogger_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 . 'prautoblogger_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 )
)
);
}
}
/**
* Strip common LLM output artifacts from generated content.
*
* LLMs sometimes ignore format instructions and wrap content in markdown
* code fences (```html ... ```) or full HTML document structures
* (<html><head><body>). This method strips those wrappers so only clean
* article-body HTML reaches WordPress.
*
* @param string $content Raw LLM output.
*
* @return string Cleaned HTML suitable for post_content.
*/
private function sanitize_llm_content( string $content ): string {
$content = trim( $content );
// Strip markdown code fences: ```html ... ``` or ``` ... ```
// Handles optional language identifier after opening fence.
if ( preg_match( '/^```(?:\w+)?\s*\n(.*?)```\s*$/s', $content, $matches ) ) {
$content = trim( $matches[1] );
}
// Strip full HTML document wrappers: <html>...<body>content</body>...</html>
// Extract only the <body> inner content.
if ( preg_match( '/<body[^>]*>(.*)<\/body>/is', $content, $matches ) ) {
$content = trim( $matches[1] );
}
// Remove stray <html>, </html>, <head>...</head>, <body>, </body> tags
// in case they appear without a proper document structure.
$content = preg_replace( '/<\/?(html|head|body|meta|title|!DOCTYPE)[^>]*>/i', '', $content );
// Remove leftover <link> and <style> tags that belong in <head>, not post content.
$content = preg_replace( '/<link\s[^>]*>/i', '', $content );
$content = preg_replace( '/<style[^>]*>.*?<\/style>/is', '', $content );
// Collapse any resulting excess whitespace (multiple blank lines → single).
$content = preg_replace( '/\n{3,}/', "\n\n", $content );
return trim( $content );
}
/**
* 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( 'prautoblogger_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;
}
/**
* Generate and attach images to a published post.
*
* Calls the image pipeline to generate Image A (featured image) and Image B
* (secondary image). Sets _thumbnail_id and _prautoblogger_image_b_id post meta.
* Gracefully handles image generation failures — post is already created, so
* this is a non-blocking operation.
*
* @param int $post_id The newly created post ID.
* @param PRAutoBlogger_Article_Idea $idea The article idea (contains source IDs).
* @param array $post_data The post data array (for article content).
*
* @return void
*/
private function attach_generated_images( int $post_id, PRAutoBlogger_Article_Idea $idea, array $post_data ): void {
// Build source data from idea's source IDs (if available).
$source_data = null;
$source_ids = $idea->get_source_ids();
if ( ! empty( $source_ids ) ) {
// Attempt to fetch the original source data for Image B.
// For now, we pass null; the image pipeline will handle gracefully.
$source_data = null;
}
try {
$image_pipeline = new PRAutoBlogger_Image_Pipeline();
$result = $image_pipeline->generate_and_attach_images( $post_id, $post_data, $source_data );
// Set Image A as the featured image.
if ( isset( $result['image_a_id'] ) ) {
set_post_thumbnail( $post_id, $result['image_a_id'] );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Set featured image (attachment %d) for post %d', $result['image_a_id'], $post_id ),
'publisher'
);
}
// Store Image B ID in post meta for A/B tracking.
if ( isset( $result['image_b_id'] ) ) {
update_post_meta( $post_id, '_prautoblogger_image_b_id', $result['image_b_id'] );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Stored Image B (attachment %d) in post meta for post %d', $result['image_b_id'], $post_id ),
'publisher'
);
}
// Log errors if any occurred (but don't let them block post creation).
if ( ! empty( $result['errors'] ) ) {
foreach ( $result['errors'] as $error ) {
PRAutoBlogger_Logger::instance()->warning(
'Image generation warning for post ' . $post_id . ': ' . $error,
'publisher'
);
}
}
// Log total cost.
if ( $result['cost_usd'] > 0.0 ) {
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Image generation cost: $%.4f for post %d', $result['cost_usd'], $post_id ),
'publisher'
);
}
} catch ( \Exception $e ) {
// Log the error but don't re-throw — post is already created.
PRAutoBlogger_Logger::instance()->warning(
'Image pipeline exception for post ' . $post_id . ': ' . $e->getMessage(),
'publisher'
);
}
return $meta;
}
}

View File

@@ -1,183 +0,0 @@
<?php
declare(strict_types=1);
/**
* Daily cron that retroactively amortizes orphan `llm_research` rows.
*
* When a pipeline run dies between post creation and the final
* `Post_Assembler::amortize_research_costs()` call (Hostinger exec kill,
* OOM, LiteSpeed cut, fatal), the shared research-cost row stays in
* `wp_prautoblogger_generation_log` with `post_id = NULL` and the
* articles from that run understate their true cost in the popover.
*
* This reaper scans for such orphan rows once a day, finds their sibling
* posts via the `_prautoblogger_run_id` post_meta (primary) or the
* gen_log's own post-linked rows (fallback for legacy posts that
* predate the meta), and delegates to the existing idempotent
* `amortize_research_costs()` method. Orphans that still have no
* siblings after 7 days are deleted outright as sunk cost.
*
* Spec-wise the CTO asked for the reap method to live on Post_Assembler;
* hosting it here instead keeps Post_Assembler under the 300-line cap
* (it grew past 300 with the reap method attached) and colocates the
* attribution logic next to the cron/CLI plumbing that triggers it.
*
* Triggered by:
* - WP-Cron: `prautoblogger_reap_orphan_research_rows` (daily, 03:15).
* - WP-CLI: `wp prautoblogger reap-research` (manual ops trigger).
* Dependencies: PRAutoBlogger_Post_Assembler (amortize_research_costs),
* PRAutoBlogger_Logger, WordPress $wpdb + get_posts.
*
* @see core/class-post-assembler.php — amortize_research_costs() does the work.
* @see class-activator.php — Schedules the daily cron.
* @see class-deactivator.php — Unschedules on deactivate.
* @see ARCHITECTURE.md — Cost tracking data flow.
*/
class PRAutoBlogger_Research_Reaper {
/** Grace window — live pipelines have this long to finish amortizing before reaping. */
private const GRACE_WINDOW_SECONDS = 3600; // 1 hour.
/** Stale window — orphans with no sibling posts are deleted outright after this long. */
private const STALE_WINDOW_SECONDS = 7 * 86400; // 7 days.
/**
* Cron action handler — entrypoint for the daily WP-Cron event.
*
* Pure delegate. Never throws.
*
* @return void
*/
public static function on_cron(): void {
self::reap();
}
/**
* Scan and reap orphan `llm_research` rows. Returns a summary that the
* WP-CLI command prints and the cron path ignores.
*
* Side effects: SELECT / INSERT / DELETE on generation_log; post_meta
* reads; one INFO log line per reaped or stale-deleted
* orphan; one WARNING on DB error.
*
* @return array{reaped: int, deleted: int, skipped: int}
*/
public static function reap(): array {
$stats = [ 'reaped' => 0, 'deleted' => 0, 'skipped' => 0 ];
try {
global $wpdb;
$log_table = $wpdb->prefix . 'prautoblogger_generation_log';
$grace = gmdate( 'Y-m-d H:i:s', time() - self::GRACE_WINDOW_SECONDS );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$orphans = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, run_id, created_at FROM {$log_table}
WHERE stage = 'llm_research' AND post_id IS NULL AND created_at < %s",
$grace
)
);
if ( ! is_array( $orphans ) ) {
return $stats;
}
$stale_cutoff = time() - self::STALE_WINDOW_SECONDS;
foreach ( $orphans as $orphan ) {
self::handle_orphan( $orphan, $stale_cutoff, $log_table, $stats );
}
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->warning(
'Reaper encountered DB error: ' . $e->getMessage(),
'cost-tracker'
);
}
return $stats;
}
/**
* Route one orphan row: amortize if siblings exist, delete if stale,
* otherwise skip and try again next run.
*
* @param object $orphan DB row with id, run_id, created_at.
* @param int $stale_cutoff Unix timestamp — orphans older than this delete.
* @param string $log_table Fully-qualified gen_log table name.
* @param array $stats Counters, mutated by reference.
* @return void
*/
private static function handle_orphan( object $orphan, int $stale_cutoff, string $log_table, array &$stats ): void {
global $wpdb;
$run_id = (string) ( $orphan->run_id ?? '' );
$created_ts = strtotime( (string) ( $orphan->created_at ?? '' ) );
$is_stale = false !== $created_ts && $created_ts < $stale_cutoff;
if ( '' === $run_id ) {
if ( $is_stale ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->delete( $log_table, [ 'id' => (int) $orphan->id ] );
$stats['deleted']++;
} else {
$stats['skipped']++;
}
return;
}
$post_ids = self::find_posts_for_run_id( $run_id );
if ( count( $post_ids ) >= 1 ) {
PRAutoBlogger_Post_Assembler::amortize_research_costs( $run_id );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Reaped research cost across %d articles for run %s.', count( $post_ids ), $run_id ),
'cost-tracker'
);
$stats['reaped']++;
return;
}
if ( $is_stale ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->delete( $log_table, [ 'id' => (int) $orphan->id ] );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Deleted 7-day-stale orphan research row for run %s — no articles to attribute.', $run_id ),
'cost-tracker'
);
$stats['deleted']++;
return;
}
$stats['skipped']++;
}
/**
* Return distinct post IDs for a given run_id. Prefers post_meta;
* falls back to gen_log rows for legacy posts.
*
* @param string $run_id Pipeline run UUID.
* @return int[] Distinct post IDs.
*/
private static function find_posts_for_run_id( string $run_id ): array {
$meta_ids = get_posts( [
'post_type' => 'any',
'post_status' => 'any',
'meta_key' => '_prautoblogger_run_id',
'meta_value' => $run_id,
'fields' => 'ids',
'posts_per_page' => -1,
'suppress_filters' => true,
] );
if ( is_array( $meta_ids ) && ! empty( $meta_ids ) ) {
return array_map( 'intval', $meta_ids );
}
global $wpdb;
$log_table = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT post_id FROM {$log_table} WHERE run_id = %s AND post_id IS NOT NULL",
$run_id
)
);
return is_array( $ids ) ? array_map( 'intval', $ids ) : [];
}
}

View File

@@ -1,272 +0,0 @@
<?php
declare(strict_types=1);
/**
* Semantic deduplication using embedding cosine similarity.
*
* Embeds candidate topics and recent post titles in a single batch API call,
* then compares via cosine similarity. Falls back to keyword-overlap matching
* if the embedding API is unavailable or fails.
*
* Triggered by: PRAutoBlogger_Idea_Scorer::score_and_rank().
* Dependencies: OpenRouter_Embedding_Provider, WP_Query.
*
* @see core/class-idea-scorer.php — Consumer.
* @see providers/class-open-router-embedding-provider.php — Embedding API.
*/
class PRAutoBlogger_Semantic_Dedup {
/**
* Cosine similarity threshold for "too similar". MiniLM embeddings treat
* ~0.7+ as semantically equivalent for short texts. We use 0.72 to avoid
* over-aggressive blocking while still catching rephrasings.
*/
private const SIMILARITY_THRESHOLD = 0.72;
/** @var float[][] Cached embeddings for recent post titles, keyed by index. */
private array $title_embeddings = [];
/** @var string[] Recent post titles. */
private array $recent_titles = [];
/** @var float[][] Cached embeddings for accepted ideas, accumulated per-batch. */
private array $accepted_embeddings = [];
/** @var bool Whether embeddings are available (false after API failure). */
private bool $embeddings_available = true;
/** @var PRAutoBlogger_OpenRouter_Embedding_Provider|null Reused across calls. */
private ?PRAutoBlogger_OpenRouter_Embedding_Provider $provider = null;
/**
* Initialize by fetching recent titles and pre-computing their embeddings.
*
* Called once at the start of scoring. If the embedding API fails,
* sets a flag so all subsequent checks fall back to keyword matching.
*
* Side effects: HTTP request to OpenRouter embedding API, WP_Query.
*/
public function initialize(): void {
$this->recent_titles = $this->fetch_recent_post_titles();
if ( empty( $this->recent_titles ) ) {
return;
}
try {
$this->provider = new PRAutoBlogger_OpenRouter_Embedding_Provider();
$this->title_embeddings = $this->provider->get_embeddings( $this->recent_titles );
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->warning(
'Semantic dedup unavailable, falling back to keyword matching: ' . $e->getMessage(),
'scorer'
);
$this->embeddings_available = false;
}
}
/**
* Check if a topic is too similar to any already-accepted idea in this batch.
*
* @param string $topic Candidate topic text.
* @param string[] $topic_keywords Pre-extracted keywords (for fallback).
* @param array $keyword_sets Accepted keyword sets (for fallback).
*
* @return bool True if the topic should be skipped.
*/
public function is_batch_duplicate( string $topic, array $topic_keywords, array $keyword_sets ): bool {
if ( ! $this->embeddings_available || empty( $this->accepted_embeddings ) ) {
return $this->keyword_overlaps_any( $topic_keywords, $keyword_sets );
}
$embedding = $this->embed_single( $topic );
if ( null === $embedding ) {
return $this->keyword_overlaps_any( $topic_keywords, $keyword_sets );
}
foreach ( $this->accepted_embeddings as $accepted ) {
$sim = PRAutoBlogger_OpenRouter_Embedding_Provider::cosine_similarity( $embedding, $accepted );
if ( $sim >= self::SIMILARITY_THRESHOLD ) {
return true;
}
}
return false;
}
/**
* Check if a topic is too similar to any recently published post.
*
* @param string $topic Candidate topic text.
* @param string[] $topic_keywords Pre-extracted keywords (for fallback).
*
* @return bool True if a recent post is semantically too similar.
*/
public function is_recent_duplicate( string $topic, array $topic_keywords ): bool {
if ( ! $this->embeddings_available || empty( $this->title_embeddings ) ) {
return $this->keyword_matches_recent( $topic_keywords );
}
$embedding = $this->embed_single( $topic );
if ( null === $embedding ) {
return $this->keyword_matches_recent( $topic_keywords );
}
foreach ( $this->title_embeddings as $i => $title_emb ) {
$sim = PRAutoBlogger_OpenRouter_Embedding_Provider::cosine_similarity( $embedding, $title_emb );
if ( $sim >= self::SIMILARITY_THRESHOLD ) {
PRAutoBlogger_Logger::instance()->info(
sprintf(
'Semantic match (%.2f): "%s" ≈ "%s"',
$sim,
substr( $topic, 0, 60 ),
substr( $this->recent_titles[ $i ] ?? '', 0, 60 )
),
'scorer'
);
return true;
}
}
return false;
}
/**
* Record an accepted idea's embedding for intra-batch dedup.
*
* @param string $topic The accepted topic text.
*/
public function record_accepted( string $topic ): void {
if ( ! $this->embeddings_available ) {
return;
}
$embedding = $this->embed_single( $topic );
if ( null !== $embedding ) {
$this->accepted_embeddings[] = $embedding;
}
}
// ── Private helpers ─────────────────────────────────────────────────
/**
* Embed a single text, with error handling.
*
* @param string $text Text to embed.
* @return float[]|null Embedding vector, or null on failure.
*/
private function embed_single( string $text ): ?array {
try {
if ( null === $this->provider ) {
$this->provider = new PRAutoBlogger_OpenRouter_Embedding_Provider();
}
$result = $this->provider->get_embeddings( [ $text ] );
return $result[0] ?? null;
} catch ( \Throwable $e ) {
// Degrade gracefully — one failed embed shouldn't kill the pipeline.
$this->embeddings_available = false;
PRAutoBlogger_Logger::instance()->warning(
'Embedding failed, disabling semantic dedup for this run: ' . $e->getMessage(),
'scorer'
);
return null;
}
}
/**
* Keyword-overlap fallback for intra-batch dedup.
*
* @param string[] $keywords Keywords to check.
* @param array $keyword_sets List of previously accepted keyword arrays.
* @return bool
*/
private function keyword_overlaps_any( array $keywords, array $keyword_sets ): bool {
if ( count( $keywords ) < 2 ) {
return false;
}
foreach ( $keyword_sets as $existing ) {
$overlap = count( array_intersect( $keywords, $existing ) );
if ( $overlap / count( $keywords ) >= 0.6 ) {
return true;
}
}
return false;
}
/**
* Keyword-overlap fallback for recent-post dedup.
*
* @param string[] $topic_keywords Pre-extracted keywords.
* @return bool
*/
private function keyword_matches_recent( array $topic_keywords ): bool {
if ( count( $topic_keywords ) < 2 ) {
return false;
}
foreach ( $this->recent_titles as $title ) {
$title_keywords = $this->extract_meaningful_keywords( $title );
$overlap = count( array_intersect( $topic_keywords, $title_keywords ) );
if ( $overlap / count( $topic_keywords ) >= 0.6 ) {
return true;
}
}
return false;
}
/**
* Fetch titles of generated posts from the last 30 days.
*
* Widened from 7 days (the old keyword window) because semantic similarity
* catches rephrasings that keywords miss — a wider net is safe here.
*
* @return string[]
*/
private function fetch_recent_post_titles(): array {
$query = new \WP_Query( [
'post_type' => 'post',
'post_status' => [ 'publish', 'draft', 'pending' ],
'meta_key' => '_prautoblogger_generated',
'meta_value' => '1',
'date_query' => [ [ 'after' => '30 days ago' ] ],
'posts_per_page' => 100,
'fields' => 'ids',
'no_found_rows' => true,
] );
$titles = [];
foreach ( $query->posts as $post_id ) {
$titles[] = get_the_title( $post_id );
}
return $titles;
}
/**
* Extract meaningful keywords from text (fallback dedup).
*
* @param string $text Input text.
* @return string[] Unique lowercase keywords.
*/
private function extract_meaningful_keywords( string $text ): array {
static $stopwords = [
'a', 'an', 'the', 'and', 'or', 'but', 'is', 'are', 'was', 'were',
'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'as',
'it', 'its', 'this', 'that', 'your', 'you', 'how', 'what', 'when',
'why', 'where', 'which', 'who', 'do', 'does', 'did', 'not', 'no',
'can', 'will', 'should', 'could', 'would', 'may', 'might', 'have',
'has', 'had', 'be', 'been', 'being', 'about', 'into', 'through',
'during', 'before', 'after', 'above', 'below', 'between', 'same',
'up', 'down', 'out', 'off', 'over', 'under', 'again', 'then',
'here', 'there', 'all', 'each', 'every', 'both', 'few', 'more',
'most', 'other', 'some', 'such', 'than', 'too', 'very', 'just',
'also', 'only', 'own', 'so', 'if', 'while', 'because', 'until',
'vs', 'versus', 'guide', 'complete', 'ultimate', 'best', 'top',
'new', 'first', 'need', 'know', 'everything',
];
$text = strtolower( $text );
$words = preg_split( '/[^a-z0-9-]+/', $text, -1, PREG_SPLIT_NO_EMPTY );
$words = array_filter( $words, static function ( string $w ) use ( $stopwords ): bool {
return strlen( $w ) >= 2 && ! in_array( $w, $stopwords, true );
} );
return array_values( array_unique( $words ) );
}
}

View File

@@ -4,40 +4,19 @@ declare(strict_types=1);
/**
* Orchestrates data collection from all enabled source providers.
*
* Iterates through enabled sources (Reddit, LLM Research), calls their
* Iterates through enabled sources (currently Reddit), calls their
* collect_data() method, and stores results in the ab_source_data table.
* Handles deduplication via UNIQUE index on (source_type, source_id).
* Re-seen items get their collected_at timestamp refreshed so the
* analyzer's 24-hour window picks them up for re-analysis.
*
* Triggered by: PRAutoBlogger::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 — Reddit source provider.
* @see providers/class-llm-research-provider.php — LLM deep research provider.
* @see providers/class-reddit-provider.php — Primary source provider.
* @see ARCHITECTURE.md — Data flow step 1.
*/
class PRAutoBlogger_Source_Collector {
/**
* Pipeline cost tracker — passed to providers that need run_id tagging.
* Null when called outside the pipeline (e.g., manual test collection).
*/
private ?PRAutoBlogger_Cost_Tracker $cost_tracker = null;
/**
* Inject the pipeline's cost tracker so source providers can log costs
* with the correct run_id. Without this, provider costs are orphaned.
*
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Pipeline cost tracker with run_id set.
* @return $this
*/
public function set_cost_tracker( PRAutoBlogger_Cost_Tracker $cost_tracker ): self {
$this->cost_tracker = $cost_tracker;
return $this;
}
/**
* Collect data from all enabled source providers and store in database.
*
@@ -46,10 +25,7 @@ class PRAutoBlogger_Source_Collector {
* @return int Total number of new records inserted.
*/
public function collect_from_all_sources(): int {
$raw = get_option( 'prautoblogger_enabled_sources', '["reddit"]' );
$enabled = json_decode( (string) $raw, true );
// Only fall back to reddit if the option is corrupt (non-JSON).
// An empty array '[]' is a valid choice — the user disabled all sources.
$enabled = json_decode( get_option( 'prautoblogger_enabled_sources', '["reddit"]' ), true );
if ( ! is_array( $enabled ) ) {
$enabled = [ 'reddit' ];
}
@@ -66,15 +42,14 @@ class PRAutoBlogger_Source_Collector {
try {
$data = $provider->collect_data( $this->get_config_for_source( $source_type ) );
$inserted = $this->store_data( $data );
$refreshed = count( $data ) - $inserted;
$total_inserted += $inserted;
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Collected %d items from %s (%d new, %d refreshed).', count( $data ), $source_type, $inserted, $refreshed ),
sprintf( 'Collected %d items from %s (%d new).', count( $data ), $source_type, $inserted ),
'collector'
);
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error( sprintf( 'Collection %s from %s: %s', get_class( $e ), $source_type, $e->getMessage() ), 'collector' );
} catch ( \Exception $e ) {
PRAutoBlogger_Logger::instance()->error( "Collection from {$source_type} FAILED: " . $e->getMessage(), 'collector' );
// Continue with other sources — one failure shouldn't stop everything.
}
}
@@ -82,61 +57,16 @@ class PRAutoBlogger_Source_Collector {
return $total_inserted;
}
/**
* Retrieve source data rows by their database IDs.
*
* Used by the image pipeline to build Image B's source-driven prompt from
* the source_ids stored on the Article_Idea. Returns the first matching
* row's title and content; these map directly to what
* Image_Prompt_Builder::build_source_prompt() expects.
*
* Side effects: one SELECT query.
*
* @param int[] $ids Database row IDs from the source_data table.
* @return array{title: string, selftext: string}|null First source row, or null if none found.
*/
public function get_source_data_for_image( array $ids ): ?array {
if ( empty( $ids ) ) {
return null;
}
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_source_data';
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT title, content, subreddit FROM {$table} WHERE id IN ({$placeholders}) ORDER BY score DESC LIMIT 1",
...$ids
),
ARRAY_A
);
if ( null === $row ) {
return null;
}
return [
'title' => $row['title'] ?? 'Reddit Discussion',
'selftext' => $row['content'] ?? '',
];
}
/**
* Store collected source data in the database.
*
* Uses INSERT … ON DUPLICATE KEY UPDATE so that re-seen items refresh
* their collected_at timestamp (plus score/comment_count which change
* on Reddit over time). Without this, items that age past the
* analyzer's 24-hour window would never be re-analyzed even though
* they keep appearing in the RSS feed.
* Uses INSERT IGNORE to handle duplicates gracefully (UNIQUE index).
*
* Side effects: database inserts/updates.
* Side effects: database inserts.
*
* @param PRAutoBlogger_Source_Data[] $items
*
* @return int Number of new records inserted (updates not counted).
* @return int Number of new records inserted.
*/
private function store_data( array $items ): int {
global $wpdb;
@@ -146,18 +76,13 @@ class PRAutoBlogger_Source_Collector {
foreach ( $items as $item ) {
$row = $item->to_db_row();
// Upsert: insert new items, refresh timestamps on duplicates so
// the analyzer's 24-hour window picks them up again.
// 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 INTO {$table}
"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)
ON DUPLICATE KEY UPDATE
collected_at = VALUES(collected_at),
score = VALUES(score),
comment_count = VALUES(comment_count)",
VALUES (%s, %s, %s, %s, %s, %s, %d, %d, %s, %s, %s)",
$row['source_type'],
$row['source_id'],
$row['subreddit'],
@@ -172,9 +97,7 @@ class PRAutoBlogger_Source_Collector {
)
);
// ON DUPLICATE KEY UPDATE returns 2 for updates, 1 for inserts.
// Only count genuine inserts toward the "new" tally.
if ( false !== $result && 1 === (int) $result ) {
if ( false !== $result && $result > 0 ) {
$inserted++;
}
}
@@ -191,8 +114,8 @@ class PRAutoBlogger_Source_Collector {
*/
private function get_provider( string $source_type ): ?PRAutoBlogger_Source_Provider_Interface {
$providers = [
'reddit' => PRAutoBlogger_Reddit_Provider::class,
'llm_research' => PRAutoBlogger_LLM_Research_Provider::class,
'reddit' => PRAutoBlogger_Reddit_Provider::class,
// New providers can be added here — see CONVENTIONS.md for how-to.
];
/**
@@ -231,27 +154,14 @@ class PRAutoBlogger_Source_Collector {
private function get_config_for_source( string $source_type ): array {
if ( 'reddit' === $source_type ) {
return [
'subreddits' => json_decode( get_option( 'prautoblogger_target_subreddits', '[]' ), true ) ?: [],
'limit' => 25,
'time_filter' => 'day',
'include_comments' => true,
'subreddits' => json_decode( get_option( 'prautoblogger_target_subreddits', '[]' ), true ) ?: [],
'limit' => 25,
'time_filter' => 'day',
'include_comments' => true,
'comments_per_post' => 10,
];
}
if ( 'llm_research' === $source_type ) {
$config = [
'model' => get_option( 'prautoblogger_research_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL ),
'prompt' => get_option( 'prautoblogger_research_prompt', '' ),
];
// Pass the pipeline's cost tracker so research costs get tagged
// with the run_id for post-pipeline amortization across articles.
if ( null !== $this->cost_tracker ) {
$config['cost_tracker'] = $this->cost_tracker;
}
return $config;
}
return [];
}
}

View File

@@ -1,256 +0,0 @@
<?php
declare(strict_types=1);
/**
* Injects frontend typography and table styles for PRAutoBlogger-generated posts.
*
* What: Outputs inline CSS on all single post pages, applying
* user-configured font family, font size, and table border
* styles from the Display admin settings.
* Who calls it: PRAutoBlogger::register_frontend_hooks() hooks this
* into wp_head.
* Dependencies: Reads wp_options for typography settings.
*
* @see class-prautoblogger.php — Registers the wp_head hook.
* @see class-settings-fields.php — Defines the Display settings section.
* @see admin/class-admin-page.php — Renders the settings form.
*/
class PRAutoBlogger_Article_Typography {
/** Option key for article font family. */
private const OPT_FONT_FAMILY = 'prautoblogger_article_font_family';
/** Option key for article body font size in pixels. */
private const OPT_FONT_SIZE = 'prautoblogger_article_font_size';
/** Option key for table border toggle. */
private const OPT_TABLE_BORDERS = 'prautoblogger_table_borders';
/**
* Output inline CSS in <head> for single post pages.
*
* Applies to all posts — typography settings are site-wide.
* The CSS targets .entry-content (PR Theme's article body class)
* and overrides the theme's CSS custom properties so the cascade
* works naturally with headings and sub-elements.
*
* Side effects: echoes a <style> block.
*
* @return void
*/
public function on_wp_head(): void {
if ( ! is_singular( 'post' ) ) {
return;
}
$css = $this->build_css();
if ( '' === $css ) {
return;
}
printf(
"\n<style id=\"prautoblogger-typography\">\n%s\n</style>\n",
$css // Already escaped — contains only sanitized option values.
);
}
/**
* Build the CSS string from current settings.
*
* Reads each setting at call time (never cached) so changes
* take effect immediately without cache invalidation.
*
* @return string CSS rules, or empty string if all defaults.
*/
private function build_css(): string {
$rules = [];
$font_family = $this->get_font_family_stack();
if ( '' !== $font_family ) {
$rules[] = sprintf(
".entry-content,\n.entry-content p,\n.entry-content li {\n\tfont-family: %s;\n}",
$font_family
);
}
$font_size = absint( get_option( self::OPT_FONT_SIZE, 0 ) );
if ( $font_size > 0 ) {
$rules[] = sprintf(
".entry-content,\n.entry-content p,\n.entry-content li {\n\tfont-size: %dpx;\n\tline-height: 1.7;\n}",
$font_size
);
}
// Restore bullet points — the theme's global reset strips
// list-style from all ul/ol, which removes bullets from article
// content. This re-applies them inside .entry-content only.
$rules[] = implode( "\n", [
'.entry-content ul {',
"\tlist-style: disc;",
"\tpadding-left: 1.5em;",
"\tmargin: 1em 0;",
'}',
'.entry-content ol {',
"\tlist-style: decimal;",
"\tpadding-left: 1.5em;",
"\tmargin: 1em 0;",
'}',
'.entry-content li {',
"\tmargin-bottom: 0.4em;",
'}',
] );
$table_borders = get_option( self::OPT_TABLE_BORDERS, '1' );
if ( '1' === $table_borders ) {
// Uses theme CSS custom properties for dark mode compatibility.
// --color-border-default, --color-bg-secondary, --color-bg-primary,
// --color-text-primary are set by PR Theme for both light and dark.
$rules[] = implode( "\n", [
'.entry-content .prab-table-wrap {',
"\toverflow-x: auto;",
"\t-webkit-overflow-scrolling: touch;",
"\tmargin: 1.5em 0;",
'}',
'.entry-content table {',
"\tborder-collapse: collapse;",
"\twidth: 100%;",
"\tmin-width: 480px;",
'}',
'.entry-content th,',
'.entry-content td {',
"\tborder: 1px solid var(--color-border-default, #d1d5db);",
"\tpadding: 0.6em 1em;",
"\ttext-align: left;",
"\tcolor: var(--color-text-primary, #111827);",
'}',
'.entry-content thead th {',
"\tbackground: var(--color-bg-secondary, #f3f4f6);",
"\tfont-weight: 600;",
'}',
'.entry-content tbody tr:nth-child(even) {',
"\tbackground: var(--color-bg-secondary, #f9fafb);",
'}',
'@media (max-width: 600px) {',
"\t.entry-content th,",
"\t.entry-content td {",
"\t\tpadding: 0.4em 0.6em;",
"\t\tfont-size: 14px;",
"\t}",
'}',
] );
}
return implode( "\n\n", $rules );
}
/**
* Map the stored font-family key to a full CSS font stack.
*
* Returns empty string for 'default' (let the theme decide).
*
* @return string CSS font-family value, or empty for theme default.
*/
private function get_font_family_stack(): string {
$key = sanitize_text_field( get_option( self::OPT_FONT_FAMILY, 'default' ) );
$stacks = [
'default' => '',
'inter' => '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
'georgia' => '"Georgia", "Times New Roman", Times, serif',
'merriweather' => '"Merriweather", Georgia, "Times New Roman", serif',
'lora' => '"Lora", Georgia, "Times New Roman", serif',
'open_sans' => '"Open Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
'roboto' => '"Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
'system' => '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", sans-serif',
];
return $stacks[ $key ] ?? '';
}
/**
* Get available font choices for the settings UI.
*
* @return array<string, string> Map of key => display label.
*/
public static function get_font_choices(): array {
return [
'default' => __( 'Theme Default (Inter)', 'prautoblogger' ),
'inter' => __( 'Inter (Sans-serif)', 'prautoblogger' ),
'georgia' => __( 'Georgia (Serif)', 'prautoblogger' ),
'merriweather' => __( 'Merriweather (Serif)', 'prautoblogger' ),
'lora' => __( 'Lora (Serif)', 'prautoblogger' ),
'open_sans' => __( 'Open Sans (Sans-serif)', 'prautoblogger' ),
'roboto' => __( 'Roboto (Sans-serif)', 'prautoblogger' ),
'system' => __( 'System Default', 'prautoblogger' ),
];
}
/**
* Enqueue Google Fonts stylesheet when a web font is selected.
*
* Only loads on singular post pages that use a Google-hosted font.
*
* Side effects: enqueues a remote stylesheet.
*
* @return void
*/
public function on_enqueue_fonts(): void {
if ( ! is_singular( 'post' ) ) {
return;
}
$font = sanitize_text_field( get_option( self::OPT_FONT_FAMILY, 'default' ) );
// Map font keys to Google Fonts URLs. Inter is already loaded by the theme.
$google_fonts = [
'merriweather' => 'https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&display=swap',
'lora' => 'https://fonts.googleapis.com/css2?family=Lora:wght@400;700&display=swap',
'open_sans' => 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&display=swap',
'roboto' => 'https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap',
];
if ( isset( $google_fonts[ $font ] ) ) {
wp_enqueue_style(
'prautoblogger-google-font',
$google_fonts[ $font ],
[],
null // No version for external CDN URLs.
);
}
}
/**
* Wrap bare <table> elements in a scrollable container for mobile.
*
* Tables wider than the viewport break mobile layouts. This wraps
* each <table> not already inside a wrapper in a
* <div class="prab-table-wrap"> that enables horizontal scrolling.
*
* Only runs on singular post pages.
*
* Side effects: modifies post HTML content via the_content filter.
*
* @param string $content Post HTML content.
* @return string Modified content with tables wrapped.
*/
public function on_wrap_tables( string $content ): string {
if ( ! is_singular( 'post' ) ) {
return $content;
}
if ( '1' !== get_option( self::OPT_TABLE_BORDERS, '1' ) ) {
return $content;
}
// Wrap <table> elements not already inside a .prab-table-wrap.
return (string) preg_replace(
'/(?<!prab-table-wrap">)\s*(<table[\s>])/i',
'<div class="prab-table-wrap">$1',
(string) preg_replace(
'/<\/table>\s*/i',
'</table></div>',
$content
)
);
}
}

View File

@@ -84,25 +84,4 @@ class PRAutoBlogger_Article_Idea {
public function get_target_keywords(): array {
return $this->target_keywords;
}
/**
* Serialize to array for storage in options/transients.
*
* Used by the pipeline queue to persist ideas across chained cron jobs.
*
* @return array<string, mixed> Constructor-compatible array.
*/
public function to_array(): array {
return [
'topic' => $this->topic,
'article_type' => $this->article_type,
'suggested_title' => $this->suggested_title,
'summary' => $this->summary,
'score' => $this->score,
'analysis_id' => $this->analysis_id,
'source_ids' => $this->source_ids,
'key_points' => $this->key_points,
'target_keywords' => $this->target_keywords,
];
}
}

View File

@@ -21,7 +21,6 @@ class PRAutoBlogger_Content_Request {
private int $max_word_count;
private string $niche_description;
private array $topic_exclusions;
private string $writing_instructions;
public function __construct(
PRAutoBlogger_Article_Idea $idea,
@@ -30,17 +29,15 @@ class PRAutoBlogger_Content_Request {
int $min_word_count,
int $max_word_count,
string $niche_description,
array $topic_exclusions = [],
string $writing_instructions = ''
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;
$this->writing_instructions = $writing_instructions;
$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(): PRAutoBlogger_Article_Idea {
@@ -70,8 +67,4 @@ class PRAutoBlogger_Content_Request {
public function get_topic_exclusions(): array {
return $this->topic_exclusions;
}
public function get_writing_instructions(): string {
return $this->writing_instructions;
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/**
* Pricing + model-id resolution helpers for the Cloudflare image provider.
*
* Kept separate from the provider so the provider stays under the 300-line
* cap and so cost estimation can be unit-tested without spinning up HTTP
* mocks. Mirrors the `class-open-router-pricing.php` split.
*
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider (both generate_image()
* and estimate_cost() delegate here).
* Dependencies: None — pure calculation + option reads.
*
* @see class-cloudflare-image-provider.php — Caller.
* @see class-open-router-pricing.php — Sibling pattern for LLM pricing.
*/
class PRAutoBlogger_Cloudflare_Image_Pricing {
/**
* Cloudflare Workers AI FLUX pricing (USD per megapixel), April 2026.
* Single source of truth — update here if Cloudflare repricies.
*/
private const COST_PER_MP_SCHNELL_USD = 0.0011;
private const COST_MULTIPLIER_DEV = 4.0;
/**
* Short-alias → fully-qualified Workers AI model id.
*
* @var array<string, string>
*/
private const MODEL_ALIAS_MAP = [
'flux-1-schnell' => '@cf/black-forest-labs/flux-1-schnell',
'flux-1-dev' => '@cf/black-forest-labs/flux-1-dev',
];
/**
* Normalize a caller's model hint to a fully-qualified Workers AI id.
*
* Accepts short aliases (`flux-1-schnell`), fully-qualified names
* (`@cf/black-forest-labs/...`), or empty. Empty input falls back to
* the site option, then to the hardcoded default.
*
* @param string $hint Caller-supplied model identifier (may be empty).
* @return string Fully-qualified Workers AI model id suitable for the URL path.
*/
public function resolve_model( string $hint ): string {
$hint = trim( $hint );
if ( '' === $hint ) {
$hint = (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL );
}
if ( 0 === strpos( $hint, '@cf/' ) ) {
return $hint;
}
return self::MODEL_ALIAS_MAP[ $hint ] ?? self::MODEL_ALIAS_MAP['flux-1-schnell'];
}
/**
* Estimate USD cost for a single image at the given dimensions.
*
* FLUX is priced per megapixel of output. Schnell is the baseline;
* [dev] is a ~4x multiplier. We clamp the MP floor at 0.01 so a
* tiny thumbnail doesn't round to zero and bypass the budget check.
*
* @param int $width Width in pixels.
* @param int $height Height in pixels.
* @param string $model Fully-qualified Workers AI model id (call resolve_model first).
* @return float USD cost rounded to 6 decimals.
*/
public function estimate_cost( int $width, int $height, string $model ): float {
$mp = max( 0.01, ( $width * $height ) / 1_000_000 );
$rate = self::COST_PER_MP_SCHNELL_USD;
if ( false !== strpos( $model, 'flux-1-dev' ) ) {
$rate *= self::COST_MULTIPLIER_DEV;
}
return round( $mp * $rate, 6 );
}
}

View File

@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
/**
* Cloudflare Workers AI image provider — FLUX.1 family.
*
* Calls `https://api.cloudflare.com/client/v4/accounts/{id}/ai/run/{model}`
* directly. We bypass the Cloudflare AI Gateway for v1 because the gateway
* route to Workers AI currently 403s (see decision D-001); the OpenRouter
* gateway path is unaffected.
*
* Triggered by: PRAutoBlogger_Image_Pipeline (commit 1b) during the content
* generation run, after editorial review, before publish. Also
* triggered by the admin "Test Connection" action.
* Dependencies: PRAutoBlogger_Encryption (token decryption), PRAutoBlogger_Logger
* (WARN/ERROR lines), PRAutoBlogger_Cloudflare_Image_Pricing
* (model resolution + cost estimation).
*
* @see interface-image-provider.php Interface this class implements.
* @see class-cloudflare-image-pricing.php Cost + model id helpers.
* @see class-cloudflare-image-support.php Token, account, URL, and log helpers.
* @see class-cloudflare-image-validator.php Credential check helper.
* @see class-open-router-provider.php Sibling provider; same retry pattern.
* @see ARCHITECTURE.md External API Integrations, key decision #16.
*/
class PRAutoBlogger_Cloudflare_Image_Provider implements PRAutoBlogger_Image_Provider_Interface {
private const PROVIDER_ID = 'cloudflare_workers_ai';
private const MIN_DIMENSION_PX = 64;
private const MAX_DIMENSION_PX = 2048;
private const DEFAULT_STEPS_SCHNELL = 4;
private const RETRY_AFTER_CAP_S = 60;
/**
* Pricing + model-id resolver. Constructed once per provider instance.
*
* @var PRAutoBlogger_Cloudflare_Image_Pricing
*/
private PRAutoBlogger_Cloudflare_Image_Pricing $pricing;
/**
* Token, account, URL, and log helpers — pulled out of this class to
* keep it under the 300-line cap.
*
* @var PRAutoBlogger_Cloudflare_Image_Support
*/
private PRAutoBlogger_Cloudflare_Image_Support $support;
/**
* Construct the provider with its default pricing + support helpers.
*/
public function __construct() {
$this->pricing = new PRAutoBlogger_Cloudflare_Image_Pricing();
$this->support = new PRAutoBlogger_Cloudflare_Image_Support();
}
/**
* Generate a single image from a prompt at the requested dimensions.
*
* Retries 5xx / 429 / network errors with exponential backoff bounded
* by PRAUTOBLOGGER_MAX_RETRIES. 4xx other than 429 fail loudly on the
* first attempt — retrying a bad prompt or a bad token wastes money.
*
* Side effects: one HTTP request per attempt; one Logger line per attempt.
*
* {@inheritDoc}
*
* @throws \InvalidArgumentException If dimensions out of range or prompt empty.
* @throws \RuntimeException On auth failure, bad response shape, or retry exhaustion.
*/
public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array {
$this->assert_valid_dimensions( $width, $height );
$prompt = trim( $prompt );
if ( '' === $prompt ) {
throw new \InvalidArgumentException(
esc_html__( 'Image prompt cannot be empty.', 'prautoblogger' )
);
}
$account_id = $this->support->get_account_id();
$api_token = $this->support->get_api_token();
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
$body = [
'prompt' => $prompt,
'width' => $width,
'height' => $height,
'steps' => (int) ( $options['steps'] ?? self::DEFAULT_STEPS_SCHNELL ),
];
if ( isset( $options['seed'] ) ) {
$body['seed'] = (int) $options['seed'];
}
$url = $this->support->build_endpoint_url( $account_id, $model );
$headers = [
'Authorization' => 'Bearer ' . $api_token,
'Content-Type' => 'application/json',
'Accept' => 'image/*',
];
$started_at = microtime( true );
$last_error = '';
for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) {
$response = wp_remote_post(
$url,
[
'timeout' => PRAUTOBLOGGER_API_TIMEOUT_SECONDS,
'headers' => $headers,
'body' => wp_json_encode( $body ),
]
);
if ( is_wp_error( $response ) ) {
$last_error = $response->get_error_message();
$this->support->log_retryable_failure( $attempt, 'network', $last_error );
$this->sleep_for_attempt( $attempt );
continue;
}
$status = (int) wp_remote_retrieve_response_code( $response );
$raw = (string) wp_remote_retrieve_body( $response );
if ( 429 === $status || $status >= 500 ) {
$last_error = sprintf( 'HTTP %d: %s', $status, substr( $raw, 0, 300 ) );
$this->support->log_retryable_failure( $attempt, (string) $status, $last_error );
if ( $attempt < PRAUTOBLOGGER_MAX_RETRIES ) {
$retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
$this->sleep_for_attempt( $attempt, $retry_after );
}
continue;
}
if ( $status >= 400 ) {
$this->support->log_client_error( $status, $api_token, $account_id, $raw );
throw new \RuntimeException(
sprintf(
/* translators: %1$d: HTTP status, %2$s: error body. */
esc_html__( 'Cloudflare Workers AI error (HTTP %1$d): %2$s', 'prautoblogger' ),
$status,
esc_html( substr( $raw, 0, 300 ) )
)
);
}
// Success. FLUX on Workers AI returns either raw image bytes
// (Content-Type: image/*) or a JSON envelope with base64 under
// `result.image`. Handle both without branching the caller.
$mime = (string) wp_remote_retrieve_header( $response, 'content-type' );
[ $raw, $mime ] = $this->normalize_response_body( $raw, $mime );
return [
'bytes' => $raw,
'mime_type' => $mime,
'width' => $width,
'height' => $height,
'model' => $model,
'seed' => isset( $body['seed'] ) ? (int) $body['seed'] : null,
'cost_usd' => $this->pricing->estimate_cost( $width, $height, $model ),
'latency_ms' => (int) ( ( microtime( true ) - $started_at ) * 1000 ),
];
}
throw new \RuntimeException(
sprintf(
/* translators: %1$d: max retries, %2$s: last error. */
esc_html__( 'Cloudflare Workers AI failed after %1$d attempts. Last error: %2$s', 'prautoblogger' ),
PRAUTOBLOGGER_MAX_RETRIES,
esc_html( $last_error )
)
);
}
/**
* Estimate the USD cost of a single generation call.
*
* {@inheritDoc}
*/
public function estimate_cost( int $width, int $height, array $options = [] ): float {
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
return $this->pricing->estimate_cost( $width, $height, $model );
}
/**
* @return string Machine-readable provider id used in logs and settings.
*/
public function get_provider_name(): string {
return self::PROVIDER_ID;
}
/**
* Verify credentials are present and the API is reachable.
*
* Delegates to PRAutoBlogger_Cloudflare_Image_Validator so the HTTP probe
* and error-shape code can be tested and sized independently.
*
* Side effects: one HTTP GET via the validator; one Logger line on failure.
*
* @return array{status: string, message: string, debug?: string}
*/
public function validate_credentials_detailed(): array {
return ( new PRAutoBlogger_Cloudflare_Image_Validator() )->run();
}
/**
* Enforce the interface's dimension contract.
*
* @param int $width Width in pixels.
* @param int $height Height in pixels.
* @return void
* @throws \InvalidArgumentException If either dimension is outside [MIN, MAX].
*/
private function assert_valid_dimensions( int $width, int $height ): void {
if (
$width < self::MIN_DIMENSION_PX || $width > self::MAX_DIMENSION_PX ||
$height < self::MIN_DIMENSION_PX || $height > self::MAX_DIMENSION_PX
) {
throw new \InvalidArgumentException(
sprintf(
/* translators: %1$d: width, %2$d: height, %3$d: min, %4$d: max. */
esc_html__( 'Image dimensions %1$dx%2$d are outside the supported range (%3$d–%4$d px per axis).', 'prautoblogger' ),
$width,
$height,
self::MIN_DIMENSION_PX,
self::MAX_DIMENSION_PX
)
);
}
}
/**
* Normalize a 2xx response into [bytes, mime-type] whether the body
* arrives as raw image bytes or as a JSON envelope with base64 image.
*
* @param string $raw Raw response body.
* @param string $mime Response Content-Type header value (may be empty).
* @return array{0: string, 1: string} [image bytes, mime type]
* @throws \RuntimeException If the body matches neither expected shape.
*/
private function normalize_response_body( string $raw, string $mime ): array {
if ( '' !== $mime && 0 === stripos( $mime, 'image/' ) ) {
return [ $raw, $mime ];
}
$decoded = json_decode( $raw, true );
if ( is_array( $decoded ) && isset( $decoded['result']['image'] ) && is_string( $decoded['result']['image'] ) ) {
$bytes = (string) base64_decode( $decoded['result']['image'], true );
if ( '' !== $bytes ) {
return [ $bytes, 'image/png' ];
}
}
throw new \RuntimeException(
esc_html__( 'Cloudflare Workers AI returned an unexpected response shape.', 'prautoblogger' )
);
}
/**
* Sleep between retries. Retry-After header value wins when present,
* otherwise exponential backoff from the PRAUTOBLOGGER_* constants.
*
* @param int $attempt 1-based attempt number that just failed.
* @param int $retry_after_secs Retry-After header value (0 if absent).
* @return void
*/
private function sleep_for_attempt( int $attempt, int $retry_after_secs = 0 ): void {
$backoff = PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 );
$delay = $retry_after_secs > 0 ? min( $retry_after_secs, self::RETRY_AFTER_CAP_S ) : $backoff;
if ( $delay > 0 ) {
sleep( $delay );
}
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
/**
* Small support helpers for the Cloudflare image provider — credential
* lookups, URL building, and structured log lines.
*
* Split out of the provider so the provider stays under the 300-line cap
* and so these tiny pieces are individually testable. Pure functions plus
* one logger forward; no state.
*
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider only.
* Dependencies: PRAutoBlogger_Encryption (token decryption),
* PRAutoBlogger_Logger (WARN/ERROR forwarding).
*
* @see class-cloudflare-image-provider.php Sole caller.
*/
class PRAutoBlogger_Cloudflare_Image_Support {
/**
* Decrypt the Cloudflare API token from the settings option.
*
* @return string Plaintext token, or empty string if not set / decrypt fails.
*/
public function get_api_token(): string {
$encrypted = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' );
if ( '' === $encrypted ) {
return '';
}
return (string) PRAutoBlogger_Encryption::decrypt( $encrypted );
}
/**
* Read the Cloudflare account ID from settings.
*
* @return string Account UUID, or empty string.
*/
public function get_account_id(): string {
return trim( (string) get_option( 'prautoblogger_cloudflare_account_id', '' ) );
}
/**
* Build the Workers AI run URL for a given account + model.
*
* @param string $account_id Cloudflare account UUID.
* @param string $model Fully-qualified Workers AI model id.
* @return string Fully-formed HTTPS URL.
*/
public function build_endpoint_url( string $account_id, string $model ): string {
return sprintf(
'https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s',
rawurlencode( $account_id ),
ltrim( $model, '/' )
);
}
/**
* Emit a WARN line for a retryable failure.
*
* @param int $attempt 1-based attempt number.
* @param string $failure_class Either 'network' or HTTP status code as string.
* @param string $detail Short failure detail for the log.
* @return void
*/
public function log_retryable_failure( int $attempt, string $failure_class, string $detail ): void {
PRAutoBlogger_Logger::instance()->warning(
sprintf(
'Cloudflare image gen %s failure (attempt %d/%d): %s',
$failure_class,
$attempt,
PRAUTOBLOGGER_MAX_RETRIES,
$detail
),
'cloudflare-image'
);
}
/**
* Log an unrecoverable 4xx with triage context. Never logs the full
* secret — only a 4-char token prefix and a 6-char account prefix.
*
* @param int $status HTTP status returned by Cloudflare.
* @param string $api_token Plaintext token.
* @param string $account_id Account UUID.
* @param string $raw Raw response body (truncated to 300 chars in the log).
* @return void
*/
public function log_client_error( int $status, string $api_token, string $account_id, string $raw ): void {
PRAutoBlogger_Logger::instance()->error(
sprintf(
'Cloudflare image gen HTTP %d (token_prefix=%s, token_len=%d, account=%s): %s',
$status,
substr( $api_token, 0, 4 ),
strlen( $api_token ),
substr( $account_id, 0, 6 ),
substr( $raw, 0, 300 )
),
'cloudflare-image'
);
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
/**
* Credential + connectivity validator for the Cloudflare image provider.
*
* Split out of the provider to keep each file under the 300-line cap and
* to keep the validation path easy to unit-test without spinning up the
* retry loop. The provider's `validate_credentials_detailed()` delegates
* straight to `run()` here.
*
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider::validate_credentials_detailed()
* which is called by the admin "Test Connection" action.
* Dependencies: PRAutoBlogger_Encryption (token decryption), PRAutoBlogger_Logger
* (WARN/ERROR lines), wp_remote_get().
*
* @see class-cloudflare-image-provider.php Parent class that delegates here.
* @see interface-image-provider.php Return-shape contract.
*/
class PRAutoBlogger_Cloudflare_Image_Validator {
private const TIMEOUT_SECONDS = 15;
/**
* Run a non-destructive credential check.
*
* Lightweight probe: hits the account-scoped AI models listing endpoint.
* Never generates a real image — that would cost money on every click.
*
* Side effects: one HTTP GET; one Logger line on failure.
*
* @return array{status: string, message: string, debug?: string}
*/
public function run(): array {
$account_id = trim( (string) get_option( 'prautoblogger_cloudflare_account_id', '' ) );
if ( '' === $account_id ) {
return $this->err(
'account_id_empty',
__( 'Cloudflare account ID is not set. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' )
);
}
$encrypted = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' );
$api_token = '' === $encrypted ? '' : (string) PRAutoBlogger_Encryption::decrypt( $encrypted );
if ( '' === $api_token ) {
return $this->err(
'token_empty',
__( 'Cloudflare API token is not set. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' )
);
}
$response = wp_remote_get(
sprintf(
'https://api.cloudflare.com/client/v4/accounts/%s/ai/models/search?per_page=1',
rawurlencode( $account_id )
),
[
'timeout' => self::TIMEOUT_SECONDS,
'headers' => [
'Authorization' => 'Bearer ' . $api_token,
'Content-Type' => 'application/json',
],
]
);
if ( is_wp_error( $response ) ) {
$msg = $response->get_error_message();
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Cloudflare credential check wp_error: %s', $msg ),
'cloudflare-image'
);
return $this->err(
'wp_error:' . $msg,
sprintf(
/* translators: %s: transport-layer error message. */
esc_html__( 'Network error reaching Cloudflare: %s', 'prautoblogger' ),
esc_html( $msg )
)
);
}
$status = (int) wp_remote_retrieve_response_code( $response );
if ( 200 === $status ) {
return [
'status' => 'ok',
'message' => __( 'Cloudflare Workers AI connected.', 'prautoblogger' ),
];
}
$body = (string) wp_remote_retrieve_body( $response );
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'Cloudflare credential check HTTP %d: %s', $status, substr( $body, 0, 200 ) ),
'cloudflare-image'
);
return $this->err(
sprintf( 'http_%d', $status ),
sprintf(
/* translators: %1$d: HTTP status, %2$s: response body. */
esc_html__( 'Cloudflare returned HTTP %1$d. %2$s', 'prautoblogger' ),
$status,
esc_html( substr( $body, 0, 200 ) )
)
);
}
/**
* @return array{status: string, message: string, debug: string}
*/
private function err( string $debug, string $message ): array {
return [
'status' => 'error',
'message' => $message,
'debug' => $debug,
];
}
}

View File

@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
/**
* Exception raised when an image provider rejects a prompt as NSFW.
*
* Image providers (Runware, OpenRouter) return provider-specific error
* shapes when their content filters block a prompt. We catch those shapes
* in each provider and raise this typed exception so the image pipeline
* can distinguish a content-filter rejection from a generic 4xx and
* retry once with a sanitized fallback prompt.
*
* Triggered by: image providers on NSFW-filter rejection.
* Dependencies: None.
*
* @see providers/class-runware-image-provider.php — May raise this.
* @see providers/class-open-router-image-provider.php — May raise this.
* @see core/class-image-nsfw-retry.php — Catches + rebuilds prompt.
*/
class PRAutoBlogger_Image_NSFW_Blocked extends \RuntimeException {
/**
* The raw upstream response body, kept verbatim for log forensics.
*
* @var string
*/
private string $upstream_body;
/**
* @param string $message Human-readable summary.
* @param string $upstream_body Raw response body from the provider.
* @param \Throwable|null $previous Optional previous exception.
*/
public function __construct( string $message, string $upstream_body = '', ?\Throwable $previous = null ) {
parent::__construct( $message, 0, $previous );
$this->upstream_body = $upstream_body;
}
/**
* @return string Raw upstream body (may be empty).
*/
public function get_upstream_body(): string {
return $this->upstream_body;
}
}

View File

@@ -1,213 +0,0 @@
<?php
declare(strict_types=1);
/**
* LLM Deep Research source provider — uses a reasoning-capable model to
* identify trending topics, emerging questions, and content gaps in the niche.
*
* Unlike the Reddit provider (which scrapes real-time discussions), this
* provider leverages an LLM's training knowledge to surface topics that
* community data might miss: recent research directions, common
* misconceptions, safety concerns, and underserved content gaps.
*
* Findings are stored as Source_Data objects alongside Reddit data so the
* Content_Analyzer sees both signals when generating article ideas.
*
* Triggered by: Source_Collector::collect_from_all_sources() when 'llm_research' is enabled.
* Dependencies: PRAutoBlogger_OpenRouter_Provider (LLM calls), PRAutoBlogger_Cost_Tracker.
*
* @see providers/interface-source-provider.php — Interface this class implements.
* @see core/class-source-collector.php — Orchestrates collection from all providers.
* @see core/class-content-analyzer.php — Consumes research findings for idea generation.
* @see ARCHITECTURE.md — Data flow step 1.
*/
class PRAutoBlogger_LLM_Research_Provider implements PRAutoBlogger_Source_Provider_Interface {
private const SOURCE_TYPE = 'llm_research';
/**
* Collect research findings by sending the configured prompt to an LLM.
*
* Sends the system prompt + user research prompt to the selected model,
* parses the JSON response, and returns each finding as a Source_Data object.
* Cost is tracked via the shared Cost_Tracker.
*
* Side effects: LLM API call via OpenRouter, cost logging.
*
* @param array{prompt?: string, model?: string, cost_tracker?: PRAutoBlogger_Cost_Tracker} $config Research-specific configuration.
* @return PRAutoBlogger_Source_Data[] Array of research findings as source data.
* @throws \RuntimeException On API error after retries.
*/
public function collect_data( array $config ): array {
$model = $config['model'] ?? get_option( 'prautoblogger_research_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
$prompt = $config['prompt'] ?? get_option( 'prautoblogger_research_prompt', '' );
$niche = get_option( 'prautoblogger_niche_description', '' );
if ( '' === trim( $prompt ) ) {
PRAutoBlogger_Logger::instance()->warning( 'LLM Research prompt is empty. Skipping.', 'llm-research' );
return [];
}
$system_prompt = $this->build_system_prompt();
$user_prompt = $this->build_user_prompt( $prompt, $niche );
$llm = new PRAutoBlogger_OpenRouter_Provider();
$response = $llm->send_chat_completion(
[
[ 'role' => 'system', 'content' => $system_prompt ],
[ 'role' => 'user', 'content' => $user_prompt ],
],
$model,
[
'temperature' => 0.7,
'max_tokens' => 8000,
'response_format' => [ 'type' => 'json_object' ],
]
);
// Use the pipeline's cost tracker if provided (for run_id tagging),
// otherwise create a standalone one (backward compat for manual calls).
$cost_tracker = $config['cost_tracker'] ?? new PRAutoBlogger_Cost_Tracker();
$cost_tracker->log_api_call(
null,
'llm_research',
$llm->get_provider_name(),
$response['model'],
$response['prompt_tokens'],
$response['completion_tokens']
);
return $this->parse_findings( $response['content'], $response['model'] );
}
/**
* @return string
*/
public function get_source_type(): string {
return self::SOURCE_TYPE;
}
/**
* Validate credentials by delegating to the OpenRouter provider.
*
* @return bool
*/
public function validate_credentials(): bool {
return ( new PRAutoBlogger_OpenRouter_Provider() )->validate_credentials();
}
/**
* LLM research has no rate limits beyond the OpenRouter provider's own.
*
* @return array{remaining: int, limit: int, resets_at: string}
*/
public function get_rate_limit_status(): array {
return [
'remaining' => 999,
'limit' => 999,
'resets_at' => '',
];
}
/**
* Build the system prompt for the research LLM.
*
* @return string
*/
private function build_system_prompt(): string {
return 'You are a deep research analyst specializing in emerging trends, '
. 'scientific developments, and community knowledge in niche health and '
. "biohacking domains. Your task is to identify substantive, actionable findings.\n\n"
. "Using your training knowledge, scan across:\n"
. "- Recent scientific literature and research directions\n"
. "- Active community discussions, debates, and evolving consensus\n"
. "- Emerging products, protocols, and methodologies gaining traction\n"
. "- Common questions, misconceptions, and points of confusion\n"
. "- Regulatory changes, safety concerns, and legal shifts\n"
. "- Gaps between what practitioners want to know and what content exists\n\n"
. "Your findings must be:\n"
. "1. SUBSTANTIVE — 2-3 paragraphs of detailed analysis per finding, not a headline\n"
. "2. ACTIONABLE — practical enough to seed a full article\n"
. "3. REALISTIC — grounded in what is actually discussed or researched; do NOT invent citations\n"
. "4. TIMELY — reflect current knowledge, recent shifts, or emerging questions\n\n"
. "Respond with valid JSON only (no preamble, no markdown fences):\n"
. '{"findings": [{"title": "string", "content": "string (2-3 paragraphs)", '
. '"relevance_score": 0-100, "category": "question|trend|comparison|guide|misconception|safety"}]}' . "\n\n"
. "Generate 8-12 findings. Prioritize depth over breadth. Avoid generic or surface-level topics.";
}
/**
* Build the user prompt by injecting the niche into the configured template.
*
* @param string $prompt_template User-configured research prompt.
* @param string $niche Niche description from settings.
* @return string
*/
private function build_user_prompt( string $prompt_template, string $niche ): string {
$niche_label = '' !== $niche ? $niche : 'your domain';
return str_replace( '{niche}', $niche_label, $prompt_template );
}
/**
* Parse the LLM's JSON response into Source_Data objects.
*
* @param string $content Raw LLM response (JSON).
* @param string $model Model that produced the response (stored as author).
* @return PRAutoBlogger_Source_Data[]
*/
private function parse_findings( string $content, string $model ): array {
$data = PRAutoBlogger_Json_Extractor::decode( $content );
if ( ! isset( $data['findings'] ) || ! is_array( $data['findings'] ) ) {
PRAutoBlogger_Logger::instance()->error(
'LLM Research response was not valid JSON or missing findings key. Raw (first 500 chars): '
. mb_substr( $content, 0, 500 ),
'llm-research'
);
return [];
}
$items = [];
$run_date = gmdate( 'Y-m-d' );
foreach ( $data['findings'] as $index => $finding ) {
$title = sanitize_text_field( $finding['title'] ?? '' );
$body = sanitize_textarea_field( $finding['content'] ?? '' );
$score = min( 100, max( 0, (int) ( $finding['relevance_score'] ?? 50 ) ) );
$category = sanitize_text_field( $finding['category'] ?? 'guide' );
if ( '' === $title || '' === $body ) {
continue;
}
// source_id uses date + index so the same day's run deduplicates
// but a new day produces fresh entries.
$source_id = 'llm_' . $run_date . '_' . $index;
$items[] = new PRAutoBlogger_Source_Data( [
'source_type' => self::SOURCE_TYPE,
'source_id' => $source_id,
'subreddit' => null,
'title' => $title,
'content' => $body,
'author' => $model,
'score' => $score,
'comment_count' => 0,
'permalink' => null,
'collected_at' => current_time( 'mysql' ),
'metadata' => [
'category' => $category,
'data_source' => 'llm_research',
'model' => $model,
],
] );
}
PRAutoBlogger_Logger::instance()->info(
sprintf( 'LLM Research produced %d findings via %s.', count( $items ), $model ),
'llm-research'
);
return $items;
}
}

View File

@@ -1,84 +0,0 @@
<?php
declare(strict_types=1);
/**
* Configuration helpers for OpenRouter provider.
*
* Resolves admin-configured settings for API endpoint selection
* (direct vs. Cloudflare AI Gateway routing) and caching behavior.
*
* Dependencies: get_option() for WordPress settings lookups.
*
* @see class-open-router-provider.php — Parent class that uses this config.
* @see class-open-router-validator.php — Also uses API base URL resolution.
* @see ARCHITECTURE.md — External API Integrations section.
*/
class PRAutoBlogger_OpenRouter_Config {
/**
* Default direct-to-OpenRouter endpoint.
*
* Used when no Cloudflare AI Gateway URL is configured. The active endpoint
* is resolved per-request via get_api_base_url() so admins can flip to an
* AI Gateway proxy without code changes.
*/
private const DEFAULT_API_BASE_URL = 'https://openrouter.ai/api/v1';
/**
* Resolve the API base URL.
*
* If an admin has configured a Cloudflare AI Gateway URL in settings,
* we route through that instead of hitting OpenRouter directly. The
* gateway is a transparent OpenAI/OpenRouter-compatible proxy that
* adds caching, cost logging, rate-limiting, and provider fallback
* — see ARCHITECTURE.md "External API Integrations".
*
* Expected gateway URL shape:
* https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openrouter
*
* @return string Base URL with no trailing slash.
*/
public function get_api_base_url(): string {
$override = trim( (string) get_option( 'prautoblogger_ai_gateway_base_url', '' ) );
if ( '' === $override ) {
return self::DEFAULT_API_BASE_URL;
}
// Only accept https to avoid plaintext-auth regressions.
if ( 0 !== stripos( $override, 'https://' ) ) {
return self::DEFAULT_API_BASE_URL;
}
return rtrim( $override, '/' );
}
/**
* Cache TTL (seconds) for AI Gateway response caching.
*
* Cloudflare honours the `cf-aig-cache-ttl` request header and serves
* cached responses for identical payloads within the TTL window. Only
* meaningful when a gateway base URL is configured; harmless otherwise.
*
* @return int Non-negative integer seconds; 0 disables caching.
*/
public function get_cache_ttl_seconds(): int {
$ttl = (int) get_option( 'prautoblogger_ai_gateway_cache_ttl', 0 );
return $ttl > 0 ? $ttl : 0;
}
/**
* Check if routing through Cloudflare AI Gateway vs. direct-to-OpenRouter.
*
* @return bool True if a gateway URL is configured.
*/
public function is_via_gateway(): bool {
return self::DEFAULT_API_BASE_URL !== $this->get_api_base_url();
}
/**
* Get the default OpenRouter API base URL constant.
*
* @return string
*/
public static function get_default_api_base_url(): string {
return self::DEFAULT_API_BASE_URL;
}
}

View File

@@ -1,179 +0,0 @@
<?php
declare(strict_types=1);
/**
* Fetches text embeddings from OpenRouter for semantic similarity comparisons.
*
* Used by the Idea Scorer to detect semantically similar topics that keyword
* matching would miss (e.g. "BPC-157 dosing" vs "how much BPC-157 to take").
* OpenRouter exposes an OpenAI-compatible /embeddings endpoint.
*
* Triggered by: PRAutoBlogger_Idea_Scorer (semantic dedup check).
* Dependencies: OpenRouter API key, OpenRouter_Config, OpenRouter_Request_Builder.
*
* @see core/class-idea-scorer.php — Consumes embeddings for dedup.
* @see providers/class-open-router-config.php — API base URL resolution.
* @see providers/class-open-router-request-builder.php — Header + cURL auth.
*/
class PRAutoBlogger_OpenRouter_Embedding_Provider {
/** Default embedding model — small, fast, nearly free ($0.02/M tokens). */
private const DEFAULT_MODEL = 'sentence-transformers/all-minilm-l12-v2';
/** Embedding dimension for MiniLM-L12-v2. Used for validation. */
private const EXPECTED_DIMENSIONS = 384;
/**
* Generate embeddings for one or more text inputs in a single API call.
*
* Batches all inputs into one request to minimize HTTP round-trips.
* Returns one embedding vector per input, in the same order.
*
* Side effects: HTTP request to OpenRouter API.
*
* @param string[] $texts Array of text strings to embed.
* @param string $model Optional model override.
*
* @return float[][] Array of embedding vectors, one per input text.
* Each vector is an array of floats (384 dimensions for MiniLM).
*
* @throws \RuntimeException On API error or malformed response.
*/
public function get_embeddings( array $texts, string $model = '' ): array {
if ( empty( $texts ) ) {
return [];
}
$api_key = $this->get_api_key();
if ( '' === $api_key ) {
throw new \RuntimeException(
__( 'OpenRouter API key is not configured.', 'prautoblogger' )
);
}
$model = '' !== $model ? $model : self::DEFAULT_MODEL;
$config = new PRAutoBlogger_OpenRouter_Config();
$base_url = $config->get_api_base_url();
$base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST );
$builder = new PRAutoBlogger_OpenRouter_Request_Builder();
$request_headers = $builder->build_headers( $api_key, $config->is_via_gateway(), 0 );
$curl_filter = $builder->register_curl_auth_filter( $request_headers, $base_host );
try {
$response = wp_remote_post(
$base_url . '/embeddings',
[
'timeout' => 30,
'headers' => $request_headers,
'body' => wp_json_encode( [
'model' => $model,
'input' => array_values( $texts ),
] ),
]
);
if ( is_wp_error( $response ) ) {
throw new \RuntimeException(
sprintf( 'Embedding request failed: %s', $response->get_error_message() )
);
}
$status = wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( $status >= 400 ) {
$msg = $body['error']['message'] ?? wp_remote_retrieve_body( $response );
throw new \RuntimeException(
sprintf( 'Embedding API error (HTTP %d): %s', $status, $msg )
);
}
return $this->parse_response( $body, count( $texts ) );
} finally {
remove_action( 'http_api_curl', $curl_filter, 99 );
}
}
/**
* Compute cosine similarity between two embedding vectors.
*
* Returns a value between -1 and 1. Values above ~0.7 indicate
* high semantic similarity for MiniLM embeddings.
*
* @param float[] $a First embedding vector.
* @param float[] $b Second embedding vector.
*
* @return float Cosine similarity score.
*/
public static function cosine_similarity( array $a, array $b ): float {
$dot = 0.0;
$norm_a = 0.0;
$norm_b = 0.0;
$len = min( count( $a ), count( $b ) );
for ( $i = 0; $i < $len; $i++ ) {
$dot += $a[ $i ] * $b[ $i ];
$norm_a += $a[ $i ] * $a[ $i ];
$norm_b += $b[ $i ] * $b[ $i ];
}
$denom = sqrt( $norm_a ) * sqrt( $norm_b );
if ( $denom < 1e-10 ) {
return 0.0;
}
return $dot / $denom;
}
/**
* Parse the OpenAI-compatible embeddings response.
*
* @param array|null $body Decoded JSON response body.
* @param int $expected_count Number of embeddings expected.
*
* @return float[][] Embedding vectors sorted by input order.
*
* @throws \RuntimeException On malformed response.
*/
private function parse_response( ?array $body, int $expected_count ): array {
if ( ! isset( $body['data'] ) || ! is_array( $body['data'] ) ) {
throw new \RuntimeException( 'Embedding response missing "data" field.' );
}
if ( count( $body['data'] ) !== $expected_count ) {
throw new \RuntimeException(
sprintf( 'Expected %d embeddings, got %d.', $expected_count, count( $body['data'] ) )
);
}
// Sort by index to match input order (API may return out of order).
usort( $body['data'], static function ( array $a, array $b ): int {
return ( $a['index'] ?? 0 ) <=> ( $b['index'] ?? 0 );
} );
$embeddings = [];
foreach ( $body['data'] as $item ) {
if ( ! isset( $item['embedding'] ) || ! is_array( $item['embedding'] ) ) {
throw new \RuntimeException( 'Embedding item missing "embedding" array.' );
}
$embeddings[] = array_map( 'floatval', $item['embedding'] );
}
return $embeddings;
}
/**
* 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( 'prautoblogger_openrouter_api_key', '' );
if ( '' === $encrypted ) {
return '';
}
return PRAutoBlogger_Encryption::decrypt( $encrypted );
}
}

View File

@@ -1,294 +0,0 @@
<?php
declare(strict_types=1);
/**
* Parallel image generation via curl_multi for OpenRouter.
*
* Fires multiple chat/completions requests concurrently using PHP's
* curl_multi interface, cutting wall-clock time from N×20s to ~20s
* regardless of how many images are requested. This solves the Image B
* timeout problem on Hostinger where max_execution_time kills the
* cron process before a sequential second request can finish.
*
* Triggered by: PRAutoBlogger_OpenRouter_Image_Provider::generate_image_batch().
* Dependencies: PRAutoBlogger_OpenRouter_Image_Support (key, parsing),
* PRAutoBlogger_OpenRouter_Image_Pricing (model, cost),
* PRAutoBlogger_OpenRouter_Config (base URL, gateway),
* PRAutoBlogger_OpenRouter_Request_Builder (headers).
*
* @see class-open-router-image-provider.php — Sole caller.
* @see class-open-router-image-support.php — Response parsing.
* @see ARCHITECTURE.md — Image pipeline data flow.
*/
class PRAutoBlogger_OpenRouter_Image_Batch {
/** @var PRAutoBlogger_OpenRouter_Image_Pricing */
private PRAutoBlogger_OpenRouter_Image_Pricing $pricing;
/** @var PRAutoBlogger_OpenRouter_Image_Support */
private PRAutoBlogger_OpenRouter_Image_Support $support;
/** @var PRAutoBlogger_OpenRouter_Config */
private PRAutoBlogger_OpenRouter_Config $config;
/** @var PRAutoBlogger_OpenRouter_Request_Builder */
private PRAutoBlogger_OpenRouter_Request_Builder $request_builder;
/**
* @param PRAutoBlogger_OpenRouter_Image_Pricing $pricing Cost + model resolver.
* @param PRAutoBlogger_OpenRouter_Image_Support $support Key + response parsing.
* @param PRAutoBlogger_OpenRouter_Config $config API URL resolution.
* @param PRAutoBlogger_OpenRouter_Request_Builder $request_builder Header builder.
*/
public function __construct(
PRAutoBlogger_OpenRouter_Image_Pricing $pricing,
PRAutoBlogger_OpenRouter_Image_Support $support,
PRAutoBlogger_OpenRouter_Config $config,
PRAutoBlogger_OpenRouter_Request_Builder $request_builder
) {
$this->pricing = $pricing;
$this->support = $support;
$this->config = $config;
$this->request_builder = $request_builder;
}
/**
* Execute multiple image generation requests concurrently via curl_multi.
*
* Each request runs in its own cURL handle. All handles execute in
* parallel — the total wall-clock time equals the slowest single
* request, not the sum of all requests.
*
* No retry logic here — each request gets one attempt. Retries would
* complicate the multi-handle lifecycle and the single-attempt success
* rate on OpenRouter is >98%. If a request fails, its key maps to an
* `{error: string}` array.
*
* @param array<string, array{prompt: string, width: int, height: int, options?: array}> $requests Keyed requests.
*
* @return array<string, array{
* bytes: string, mime_type: string, width: int, height: int,
* model: string, seed: ?int, cost_usd: float, latency_ms: int,
* }|array{error: string}> Results indexed by the same keys as $requests.
*/
public function execute( array $requests ): array {
if ( empty( $requests ) ) {
return [];
}
$api_key = $this->support->get_api_key();
$base_url = $this->config->get_api_base_url();
$url = $base_url . '/chat/completions';
$headers = $this->request_builder->build_headers(
$api_key,
$this->config->is_via_gateway(),
0
);
// Convert associative headers to curl-style indexed array.
$curl_headers = [];
foreach ( $headers as $name => $value ) {
$curl_headers[] = $name . ': ' . $value;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_init
$multi_handle = curl_multi_init();
$handles = [];
$start_times = [];
foreach ( $requests as $key => $req ) {
$model = $this->pricing->resolve_model( (string) ( $req['options']['model'] ?? '' ) );
$payload = $this->build_request_body(
$req['prompt'],
$req['width'],
$req['height'],
$model
);
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_init
$ch = curl_init();
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
curl_setopt( $ch, CURLOPT_URL, $url );
curl_setopt( $ch, CURLOPT_POST, true );
curl_setopt( $ch, CURLOPT_POSTFIELDS, wp_json_encode( $payload ) );
curl_setopt( $ch, CURLOPT_HTTPHEADER, $curl_headers );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_TIMEOUT, PRAUTOBLOGGER_API_TIMEOUT_SECONDS );
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
// phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_setopt
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_add_handle
curl_multi_add_handle( $multi_handle, $ch );
$handles[ $key ] = [ 'handle' => $ch, 'model' => $model, 'req' => $req ];
$start_times[ $key ] = microtime( true );
}
// Execute all handles concurrently.
$this->run_multi( $multi_handle );
// Collect results.
$results = [];
foreach ( $handles as $key => $entry ) {
$results[ $key ] = $this->collect_result(
(string) $key,
$entry['handle'],
$entry['model'],
$entry['req'],
$start_times[ $key ]
);
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_remove_handle
curl_multi_remove_handle( $multi_handle, $entry['handle'] );
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_close
curl_close( $entry['handle'] );
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_close
curl_multi_close( $multi_handle );
return $results;
}
/**
* Drive curl_multi_exec to completion.
*
* @param \CurlMultiHandle $mh Multi handle.
*/
private function run_multi( $mh ): void {
$running = 0;
do {
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_exec
$status = curl_multi_exec( $mh, $running );
if ( $running > 0 ) {
// Block until activity on any handle (up to 1s).
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_select
curl_multi_select( $mh, 1.0 );
}
} while ( $running > 0 && CURLM_OK === $status );
}
/**
* Parse the result of one cURL handle into image data or error.
*
* All failure paths emit `Logger::error` at the point they fold the
* request into an `['error' => ...]` array — tagged with the request
* `$key` so the pipeline can attribute which of the parallel images
* failed (image_a vs image_b) without cross-referencing timestamps.
*
* @param string $key Request key from the batch map (e.g. "image_a").
* @param \CurlHandle $ch Individual cURL handle (completed).
* @param string $model Resolved model id.
* @param array $req Original request array.
* @param float $start Microtime when this handle was created.
*
* @return array Image data array or `{error: string}`.
*
* Side effects: writes `Logger::error` on any failure path.
*/
private function collect_result( string $key, $ch, string $model, array $req, float $start ): array {
$latency_ms = (int) ( ( microtime( true ) - $start ) * 1000 );
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_errno
$curl_errno = curl_errno( $ch );
if ( 0 !== $curl_errno ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_error
$detail = curl_error( $ch );
$msg = sprintf(
'OpenRouter batch image gen cURL error %d for key "%s": %s',
$curl_errno,
$key,
$detail
);
PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' );
return [ 'error' => $msg ];
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_getinfo
$http_code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_getcontent
$raw = (string) curl_multi_getcontent( $ch );
if ( $http_code >= 400 ) {
$msg = sprintf(
'OpenRouter batch image gen HTTP %d for key "%s": %s',
$http_code,
$key,
substr( $raw, 0, 300 )
);
PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' );
return [ 'error' => $msg ];
}
try {
$image_bytes = $this->support->extract_image_bytes( $raw );
} catch ( \Throwable $e ) {
$msg = sprintf(
'OpenRouter batch image gen parse error for key "%s": %s',
$key,
$e->getMessage()
);
PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' );
return [ 'error' => $msg ];
}
return [
'bytes' => $image_bytes,
'mime_type' => 'image/png',
'width' => $req['width'],
'height' => $req['height'],
'model' => $model,
'seed' => null,
'cost_usd' => $this->pricing->estimate_cost( $req['width'], $req['height'], $model ),
'latency_ms' => $latency_ms,
];
}
/**
* Build the chat/completions payload for one image request.
*
* Duplicated from the provider class to keep this file self-contained
* without adding a public method to the provider's API surface.
*
* @param string $prompt Prompt text.
* @param int $width Width in pixels.
* @param int $height Height in pixels.
* @param string $model Model id.
* @return array<string, mixed>
*/
private function build_request_body( string $prompt, int $width, int $height, string $model ): array {
$target = $height > 0 ? (float) $width / (float) $height : 1.0;
// Snap to nearest standard aspect ratio.
$aspects = [
[ 'w' => 1, 'h' => 1, 'r' => 1.0 ],
[ 'w' => 3, 'h' => 2, 'r' => 1.5 ],
[ 'w' => 2, 'h' => 3, 'r' => 0.6667 ],
[ 'w' => 4, 'h' => 3, 'r' => 1.3333 ],
[ 'w' => 3, 'h' => 4, 'r' => 0.75 ],
[ 'w' => 16, 'h' => 9, 'r' => 1.7778 ],
[ 'w' => 9, 'h' => 16, 'r' => 0.5625 ],
];
$best = $aspects[0];
$best_d = abs( $target - $best['r'] );
foreach ( $aspects as $c ) {
$d = abs( $target - $c['r'] );
if ( $d < $best_d ) {
$best = $c;
$best_d = $d;
}
}
$aspect = $best['w'] . ':' . $best['h'];
return [
'model' => $model,
'messages' => [
[
'role' => 'user',
'content' => 'Generate an image: ' . $prompt,
],
],
'modalities' => [ 'image', 'text' ],
'image_config' => [ 'aspect_ratio' => $aspect ],
];
}
}

View File

@@ -1,94 +0,0 @@
<?php
declare(strict_types=1);
/**
* Pricing + model-id resolution helpers for the OpenRouter image provider.
*
* Mirrors the Runware pricing helper pattern. Each model's cost per image
* is a flat rate sourced from OpenRouter's pricing page (April 2026).
*
* Triggered by: PRAutoBlogger_OpenRouter_Image_Provider (both generate_image()
* and estimate_cost() delegate here).
* Dependencies: None — pure calculation + option reads.
*
* @see class-openrouter-image-provider.php — Caller.
* @see class-runware-image-pricing.php — Sibling pattern for Runware.
*/
class PRAutoBlogger_OpenRouter_Image_Pricing {
/**
* Flat per-image cost (USD) for each supported model, April 2026.
* Update here when OpenRouter reprices or new models launch.
*
* @var array<string, float>
*/
private const COST_PER_IMAGE = [
'google/gemini-2.5-flash-image' => 0.005,
'google/gemini-3.1-flash-image-preview' => 0.008,
'google/gemini-3-pro-image-preview' => 0.03,
'openai/gpt-5-image-mini' => 0.02,
'openai/gpt-5-image' => 0.08,
];
/**
* Resolve the model identifier from a hint, falling back to site option,
* then to the PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL constant.
*
* Never silently swaps to a different model — if the user picked something,
* we send it to OpenRouter and let the API error if it's invalid. That way
* the user sees a clear log message instead of getting billed for a model
* they didn't choose.
*
* @param string $hint Caller-supplied model identifier (may be empty).
* @return string OpenRouter model id.
*/
public function resolve_model( string $hint ): string {
$hint = trim( $hint );
if ( '' !== $hint ) {
return $hint;
}
$saved = trim( (string) get_option( 'prautoblogger_image_model', '' ) );
if ( '' !== $saved ) {
return $saved;
}
// Last resort: use the constant from prautoblogger.php (admin can change it).
return defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' )
? PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL
: 'google/gemini-2.5-flash-image';
}
/**
* Estimate USD cost for a single image at the given model.
*
* OpenRouter image models use flat per-image pricing, not per-megapixel.
* Width/height are accepted for interface compatibility but don't affect cost.
*
* @param int $width Width in pixels (unused for flat pricing).
* @param int $height Height in pixels (unused for flat pricing).
* @param string $model Resolved OpenRouter model id.
* @return float USD cost rounded to 6 decimals.
*/
public function estimate_cost( int $width, int $height, string $model ): float {
if ( isset( self::COST_PER_IMAGE[ $model ] ) ) {
return round( self::COST_PER_IMAGE[ $model ], 6 );
}
// Unknown model — log it and use a conservative $0.05 estimate.
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'No pricing data for image model "%s". Using $0.05 estimate.', $model ),
'openrouter-image'
);
return 0.05;
}
/**
* Get the full model cost table for admin display.
*
* @return array<string, float> Model id => cost per image in USD.
*/
public static function get_model_costs(): array {
return self::COST_PER_IMAGE;
}
}

View File

@@ -1,300 +0,0 @@
<?php
declare(strict_types=1);
/**
* OpenRouter image provider — model-agnostic, works with any image model.
*
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation.
* Dependencies: Image_Support (key/parsing), Config (URL), Request_Builder (headers),
* Image_Pricing (model/cost), Image_Batch (parallel curl_multi), Logger.
*
* @see interface-image-provider.php — Contract.
* @see class-open-router-image-batch.php — Parallel generation via curl_multi.
* @see class-open-router-image-support.php — Key, response parsing, retry.
* @see ARCHITECTURE.md — External API Integrations table.
*/
class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Provider_Interface {
private const PROVIDER_ID = 'openrouter';
private const MIN_DIMENSION_PX = 64;
private const MAX_DIMENSION_PX = 2048;
/** @var PRAutoBlogger_OpenRouter_Image_Pricing */
private PRAutoBlogger_OpenRouter_Image_Pricing $pricing;
/** @var PRAutoBlogger_OpenRouter_Image_Support */
private PRAutoBlogger_OpenRouter_Image_Support $support;
/** @var PRAutoBlogger_OpenRouter_Config */
private PRAutoBlogger_OpenRouter_Config $config;
/** @var PRAutoBlogger_OpenRouter_Request_Builder */
private PRAutoBlogger_OpenRouter_Request_Builder $request_builder;
public function __construct() {
$this->pricing = new PRAutoBlogger_OpenRouter_Image_Pricing();
$this->support = new PRAutoBlogger_OpenRouter_Image_Support();
$this->config = new PRAutoBlogger_OpenRouter_Config();
$this->request_builder = new PRAutoBlogger_OpenRouter_Request_Builder();
}
/**
* {@inheritDoc}
*
* Retries 5xx/429 with exponential backoff; 4xx (except 429) fail immediately.
*/
public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array {
$this->assert_valid_dimensions( $width, $height );
$prompt = trim( $prompt );
if ( '' === $prompt ) {
throw new \InvalidArgumentException(
esc_html__( 'Image prompt cannot be empty.', 'prautoblogger' )
);
}
$api_key = $this->support->get_api_key();
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
$body = $this->build_request_body( $prompt, $width, $height, $model );
$base_url = $this->config->get_api_base_url();
$url = $base_url . '/chat/completions';
$headers = $this->request_builder->build_headers(
$api_key,
$this->config->is_via_gateway(),
0 // No caching for image gen — every call should be unique.
);
$curl_filter = $this->request_builder->register_curl_auth_filter(
$headers,
wp_parse_url( $base_url, PHP_URL_HOST ) ?: ''
);
$started_at = microtime( true );
$last_error = '';
try {
for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) {
$response = wp_remote_post( $url, [
'timeout' => PRAUTOBLOGGER_API_TIMEOUT_SECONDS,
'headers' => $headers,
'body' => wp_json_encode( $body ),
] );
if ( is_wp_error( $response ) ) {
$last_error = $response->get_error_message();
$this->support->log_retry( $attempt, 'network', $last_error );
$this->support->backoff( $attempt );
continue;
}
$status = (int) wp_remote_retrieve_response_code( $response );
$raw = (string) wp_remote_retrieve_body( $response );
if ( 429 === $status || $status >= 500 ) {
$last_error = sprintf( 'HTTP %d: %s', $status, substr( $raw, 0, 300 ) );
$this->support->log_retry( $attempt, (string) $status, $last_error );
if ( $attempt < PRAUTOBLOGGER_MAX_RETRIES ) {
$retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
$this->support->backoff( $attempt, $retry_after );
}
continue;
}
if ( $status >= 400 ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'OpenRouter image gen HTTP %d: %s', $status, substr( $raw, 0, 300 ) ),
'openrouter-image'
);
throw new \RuntimeException(
sprintf(
esc_html__( 'OpenRouter image error (HTTP %1$d): %2$s', 'prautoblogger' ),
$status,
esc_html( substr( $raw, 0, 300 ) )
)
);
}
$image_bytes = $this->support->extract_image_bytes( $raw );
return [
'bytes' => $image_bytes,
'mime_type' => 'image/png',
'width' => $width,
'height' => $height,
'model' => $model,
'seed' => null,
'cost_usd' => $this->pricing->estimate_cost( $width, $height, $model ),
'latency_ms' => (int) ( ( microtime( true ) - $started_at ) * 1000 ),
];
}
} finally {
remove_action( 'http_api_curl', $curl_filter, 99 );
}
throw new \RuntimeException(
sprintf(
esc_html__( 'OpenRouter image gen failed after %1$d attempts. Last error: %2$s', 'prautoblogger' ),
PRAUTOBLOGGER_MAX_RETRIES,
esc_html( $last_error )
)
);
}
/**
* Generate multiple images concurrently via curl_multi.
*
* Fires all requests in parallel — total wall-clock time equals the
* slowest single request, not the sum. Solves the Image B timeout
* problem where sequential calls exceed Hostinger's max_execution_time.
*
* {@inheritDoc}
*/
public function generate_image_batch( array $requests ): array {
// Validate all requests upfront before starting any HTTP calls.
foreach ( $requests as $key => $req ) {
$this->assert_valid_dimensions( $req['width'], $req['height'] );
if ( '' === trim( $req['prompt'] ) ) {
throw new \InvalidArgumentException(
sprintf( 'Image prompt for key "%s" cannot be empty.', esc_html( $key ) )
);
}
}
$batch = new PRAutoBlogger_OpenRouter_Image_Batch(
$this->pricing,
$this->support,
$this->config,
$this->request_builder
);
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Starting parallel image batch (%d images)', count( $requests ) ),
'openrouter-image'
);
return $batch->execute( $requests );
}
/** {@inheritDoc} */
public function estimate_cost( int $width, int $height, array $options = [] ): float {
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
return $this->pricing->estimate_cost( $width, $height, $model );
}
/** {@inheritDoc} */
public function get_provider_name(): string {
return self::PROVIDER_ID;
}
/** {@inheritDoc} — Calls OpenRouter's /auth/key endpoint. */
public function validate_credentials_detailed(): array {
try {
$api_key = $this->support->get_api_key();
} catch ( \RuntimeException $e ) {
return [ 'status' => 'error', 'message' => $e->getMessage() ];
}
$response = wp_remote_get( 'https://openrouter.ai/api/v1/auth/key', [
'timeout' => 15,
'headers' => [ 'Authorization' => 'Bearer ' . $api_key ],
] );
if ( is_wp_error( $response ) ) {
return [
'status' => 'error',
'message' => __( 'Could not reach OpenRouter API.', 'prautoblogger' ),
'debug' => $response->get_error_message(),
];
}
$status = (int) wp_remote_retrieve_response_code( $response );
if ( 200 === $status ) {
return [ 'status' => 'ok', 'message' => __( 'OpenRouter API key valid.', 'prautoblogger' ) ];
}
return [
'status' => 'error',
'message' => sprintf( __( 'OpenRouter returned HTTP %d.', 'prautoblogger' ), $status ),
'debug' => substr( (string) wp_remote_retrieve_body( $response ), 0, 200 ),
];
}
/**
* Supported aspect ratios for OpenRouter image models.
*
* @var array<int, array{w: int, h: int, ratio: float}>
*/
private const STANDARD_ASPECTS = [
[ 'w' => 1, 'h' => 1, 'ratio' => 1.0 ],
[ 'w' => 3, 'h' => 2, 'ratio' => 1.5 ],
[ 'w' => 2, 'h' => 3, 'ratio' => 0.6667 ],
[ 'w' => 4, 'h' => 3, 'ratio' => 1.3333 ],
[ 'w' => 3, 'h' => 4, 'ratio' => 0.75 ],
[ 'w' => 16, 'h' => 9, 'ratio' => 1.7778 ],
[ 'w' => 9, 'h' => 16, 'ratio' => 0.5625 ],
];
private function build_request_body( string $prompt, int $width, int $height, string $model ): array {
$aspect = $this->snap_aspect_ratio( $width, $height );
return [
'model' => $model,
'messages' => [
[
'role' => 'user',
'content' => 'Generate an image: ' . $prompt,
],
],
// Use ["image", "text"] — some providers (Gemini) require both
// modalities even when we only want the image output.
'modalities' => [ 'image', 'text' ],
'image_config' => [
'aspect_ratio' => $aspect,
],
];
}
/**
* Snap pixel dimensions to the nearest supported aspect ratio string.
*
* @param int $width Width in pixels.
* @param int $height Height in pixels.
* @return string Aspect ratio string like "16:9".
*/
private function snap_aspect_ratio( int $width, int $height ): string {
$target = $height > 0 ? (float) $width / (float) $height : 1.0;
$best = self::STANDARD_ASPECTS[0];
$best_d = abs( $target - $best['ratio'] );
foreach ( self::STANDARD_ASPECTS as $candidate ) {
$d = abs( $target - $candidate['ratio'] );
if ( $d < $best_d ) {
$best = $candidate;
$best_d = $d;
}
}
return $best['w'] . ':' . $best['h'];
}
/**
* Enforce the interface's dimension contract.
*
* @param int $width Width in pixels.
* @param int $height Height in pixels.
* @throws \InvalidArgumentException If outside [MIN, MAX].
*/
private function assert_valid_dimensions( int $width, int $height ): void {
if (
$width < self::MIN_DIMENSION_PX || $width > self::MAX_DIMENSION_PX ||
$height < self::MIN_DIMENSION_PX || $height > self::MAX_DIMENSION_PX
) {
throw new \InvalidArgumentException(
sprintf(
esc_html__( 'Image dimensions %1$dx%2$d outside supported range (%3$d–%4$d px).', 'prautoblogger' ),
$width, $height, self::MIN_DIMENSION_PX, self::MAX_DIMENSION_PX
)
);
}
}
}

View File

@@ -1,154 +0,0 @@
<?php
declare(strict_types=1);
/**
* Support helpers for the OpenRouter image provider — API key lookup,
* response parsing, retry backoff, and logging.
*
* Split from the provider to keep both files under the 300-line cap.
*
* Triggered by: PRAutoBlogger_OpenRouter_Image_Provider only.
* Dependencies: PRAutoBlogger_Encryption, PRAutoBlogger_Logger.
*
* @see class-openrouter-image-provider.php — Sole caller.
*/
class PRAutoBlogger_OpenRouter_Image_Support {
/**
* Get the decrypted OpenRouter API key from settings.
*
* @return string Plaintext key.
* @throws \RuntimeException If key is not configured or decrypt fails.
*/
public function get_api_key(): string {
$stored = (string) get_option( 'prautoblogger_openrouter_api_key', '' );
if ( '' === $stored ) {
throw new \RuntimeException(
__( 'OpenRouter API key is not configured. Go to PRAutoBlogger → Settings.', 'prautoblogger' )
);
}
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
$decrypted = PRAutoBlogger_Encryption::decrypt( $stored );
if ( '' === $decrypted ) {
throw new \RuntimeException(
__( 'OpenRouter API key could not be decrypted. Re-enter in Settings.', 'prautoblogger' )
);
}
return $decrypted;
}
return $stored;
}
/**
* Extract raw image bytes from OpenRouter's chat/completions response.
*
* The response contains choices[0].message.images[0].image_url.url
* as a data URI: "data:image/png;base64,{bytes}".
*
* @param string $raw Raw JSON response body.
* @return string Decoded image bytes.
* @throws \RuntimeException If the response doesn't contain an image.
*/
public function extract_image_bytes( string $raw ): string {
$decoded = json_decode( $raw, true );
if ( ! is_array( $decoded ) ) {
throw new \RuntimeException(
esc_html__( 'OpenRouter returned invalid JSON.', 'prautoblogger' )
);
}
// OpenRouter routers may wrap upstream errors in an HTTP 200 body.
if ( isset( $decoded['error']['message'] ) ) {
throw new \RuntimeException(
sprintf(
esc_html__( 'OpenRouter upstream image error: %s', 'prautoblogger' ),
esc_html( $decoded['error']['message'] )
)
);
}
$images = $decoded['choices'][0]['message']['images'] ?? [];
if ( empty( $images ) ) {
throw new \RuntimeException(
esc_html__( 'OpenRouter response contained no images.', 'prautoblogger' )
);
}
$data_url = $images[0]['image_url']['url'] ?? '';
if ( '' === $data_url ) {
throw new \RuntimeException(
esc_html__( 'OpenRouter image URL is empty.', 'prautoblogger' )
);
}
$comma_pos = strpos( $data_url, ',' );
if ( false === $comma_pos ) {
throw new \RuntimeException(
esc_html__( 'OpenRouter image data URI is malformed.', 'prautoblogger' )
);
}
$b64 = substr( $data_url, $comma_pos + 1 );
$bytes = base64_decode( $b64, true );
if ( false === $bytes || '' === $bytes ) {
throw new \RuntimeException(
esc_html__( 'OpenRouter image base64 decode failed.', 'prautoblogger' )
);
}
return $bytes;
}
/**
* Emit a WARN line for a retryable image-gen failure.
*
* @param int $attempt 1-based attempt number.
* @param string $class 'network' or HTTP status code.
* @param string $detail Short failure detail.
*/
public function log_retry( int $attempt, string $class, string $detail ): void {
PRAutoBlogger_Logger::instance()->warning(
sprintf(
'OpenRouter image gen %s failure (attempt %d/%d): %s',
$class,
$attempt,
PRAUTOBLOGGER_MAX_RETRIES,
$detail
),
'openrouter-image'
);
}
/**
* Sleep between retries with exponential backoff.
*
* @param int $attempt 1-based attempt that just failed.
* @param int $retry_after Retry-After header value (0 if absent).
*/
public function backoff( int $attempt, int $retry_after = 0 ): void {
$delay = $retry_after > 0
? min( $retry_after, 60 )
: PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 );
if ( $delay > 0 ) {
sleep( $delay );
}
}
/**
* Greatest common divisor (for aspect ratio calculation).
*
* @param int $a First number.
* @param int $b Second number.
* @return int GCD.
*/
public function gcd( int $a, int $b ): int {
while ( 0 !== $b ) {
$t = $b;
$b = $a % $b;
$a = $t;
}
return max( $a, 1 );
}
}

View File

@@ -114,11 +114,6 @@ class PRAutoBlogger_OpenRouter_Pricing {
* @return float Estimated cost in USD.
*/
public function estimate_cost( string $model, int $prompt_tokens, int $completion_tokens ): float {
// OpenRouter free models: the `openrouter/free` router and any `:free` suffix model.
if ( 'openrouter/free' === $model || ':free' === substr( $model, -5 ) ) {
return 0.0;
}
$pricing = self::MODEL_PRICING[ $model ] ?? null;
// Fall back to cached model list pricing.

View File

@@ -11,17 +11,60 @@ declare(strict_types=1);
* Triggered by: Content_Analyzer, Content_Generator, Chief_Editor, Metrics_Collector.
* Dependencies: PRAutoBlogger_Encryption (for API key decryption), wp_remote_post(), PRAutoBlogger_OpenRouter_Pricing.
*
* @see interface-llm-provider.php — Interface this class implements.
* @see class-open-router-config.php — Config helpers (base URL, cache TTL).
* @see class-open-router-pricing.php — Pricing and model list lookups.
* @see class-open-router-validator.php — Credential validation (delegated).
* @see class-open-router-request-builder.php — Request header building + cURL filter.
* @see class-open-router-response-parser.php — Response parsing + error classification.
* @see class-cost-tracker.php — Called after every request to log token usage.
* @see ARCHITECTURE.md — Data flow diagram showing where this fits.
* @see interface-llm-provider.php — Interface this class implements.
* @see class-open-router-pricing.php — Pricing and model list lookups.
* @see class-cost-tracker.php — Called after every request to log token usage.
* @see ARCHITECTURE.md — Data flow diagram showing where this fits.
*/
class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_Interface {
/**
* Default direct-to-OpenRouter endpoint. Used when no Cloudflare AI Gateway
* URL is configured. The active endpoint is resolved per-request via
* get_api_base_url() so admins can flip to an AI Gateway proxy without code.
*/
private const DEFAULT_API_BASE_URL = 'https://openrouter.ai/api/v1';
/**
* Resolve the API base URL.
*
* If an admin has configured a Cloudflare AI Gateway URL in settings,
* we route through that instead of hitting OpenRouter directly. The
* gateway is a transparent OpenAI/OpenRouter-compatible proxy that
* adds caching, cost logging, rate-limiting, and provider fallback
* — see ARCHITECTURE.md "External API Integrations".
*
* Expected gateway URL shape:
* https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openrouter
*
* @return string Base URL with no trailing slash.
*/
private function get_api_base_url(): string {
$override = trim( (string) get_option( 'prautoblogger_ai_gateway_base_url', '' ) );
if ( '' === $override ) {
return self::DEFAULT_API_BASE_URL;
}
// Only accept https to avoid plaintext-auth regressions.
if ( 0 !== stripos( $override, 'https://' ) ) {
return self::DEFAULT_API_BASE_URL;
}
return rtrim( $override, '/' );
}
/**
* Cache TTL (seconds) for AI Gateway response caching.
*
* Cloudflare honours the `cf-aig-cache-ttl` request header and serves
* cached responses for identical payloads within the TTL window. Only
* meaningful when a gateway base URL is configured; harmless otherwise.
*
* @return int Non-negative integer seconds; 0 disables caching.
*/
private function get_cache_ttl_seconds(): int {
$ttl = (int) get_option( 'prautoblogger_ai_gateway_cache_ttl', 0 );
return $ttl > 0 ? $ttl : 0;
}
/**
* Send a chat completion request to OpenRouter.
*
@@ -36,8 +79,7 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
* temperature?: float,
* max_tokens?: int,
* response_format?: array{type: string},
* reasoning?: array{enabled: bool, effort?: string},
* } $options Optional parameters. Pass 'reasoning' to override the global setting.
* } $options Optional parameters.
*
* @return array{
* content: string,
@@ -89,31 +131,51 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
$body['response_format'] = $options['response_format'];
}
// Reasoning mode: explicit caller override takes priority, then global setting.
if ( isset( $options['reasoning'] ) ) {
$body['reasoning'] = $options['reasoning'];
} elseif ( '1' === get_option( 'prautoblogger_reasoning_enabled', '0' ) ) {
$body['reasoning'] = [
'enabled' => true,
'effort' => get_option( 'prautoblogger_reasoning_effort', 'medium' ),
];
}
$last_error = '';
$config = new PRAutoBlogger_OpenRouter_Config();
$base_url = $config->get_api_base_url();
$base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST );
$cache_ttl = $config->get_cache_ttl_seconds();
$via_gateway = $config->is_via_gateway();
$base_url = $this->get_api_base_url();
$base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST );
$cache_ttl = $this->get_cache_ttl_seconds();
$via_gateway = self::DEFAULT_API_BASE_URL !== $base_url;
$builder = new PRAutoBlogger_OpenRouter_Request_Builder();
$request_headers = $builder->build_headers( $api_key, $via_gateway, $cache_ttl );
$curl_auth_filter = $builder->register_curl_auth_filter( $request_headers, $base_host );
// Build headers once so the wp_remote_post call and the cURL belt-and-
// suspenders filter stay in lockstep. The cf-aig-* headers are only
// meaningful when going through a Cloudflare AI Gateway; they are
// ignored by direct OpenRouter calls, so sending them unconditionally
// when a gateway is configured is safe and keeps the code branch-free.
$request_headers = [
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json',
'HTTP-Referer' => home_url(),
'X-Title' => 'PRAutoBlogger WordPress Plugin',
];
if ( $via_gateway && $cache_ttl > 0 ) {
$request_headers['cf-aig-cache-ttl'] = (string) $cache_ttl;
}
// Belt-and-suspenders: inject Authorization header at cURL level.
// Some hosting environments (Hostinger, certain proxies) strip the
// Authorization header from wp_remote_post's 'headers' array before
// the request is sent. The http_api_curl action fires after WordPress
// configures the cURL handle but before curl_exec — re-setting
// CURLOPT_HTTPHEADER here ensures the header reaches the upstream
// (either OpenRouter directly or the Cloudflare AI Gateway proxy).
$curl_auth_filter = function ( $handle, $parsed_args, $url ) use ( $request_headers, $base_host ): void {
// Scope to the configured upstream host only — never leak auth
// into unrelated outbound requests made elsewhere in WordPress.
if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) {
return;
}
$curl_headers = [];
foreach ( $request_headers as $name => $value ) {
$curl_headers[] = $name . ': ' . $value;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
curl_setopt( $handle, CURLOPT_HTTPHEADER, $curl_headers );
};
add_action( 'http_api_curl', $curl_auth_filter, 99, 3 );
try {
$parser = new PRAutoBlogger_OpenRouter_Response_Parser();
for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) {
$response = wp_remote_post(
$base_url . '/chat/completions',
@@ -143,7 +205,7 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
$data = json_decode( $body_raw, true );
// Rate limited (429) or server error (5xx) — retry with backoff.
if ( $parser->is_retryable( $status_code ) ) {
if ( 429 === $status_code || $status_code >= 500 ) {
$last_error = sprintf( 'HTTP %d: %s', $status_code, $body_raw );
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'OpenRouter HTTP %d (attempt %d/%d): %s', $status_code, $attempt, PRAUTOBLOGGER_MAX_RETRIES, substr( $body_raw, 0, 500 ) ),
@@ -163,7 +225,9 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
// Client error — don't retry, fail immediately.
if ( $status_code >= 400 ) {
$error_msg = $parser->get_error_message( $data, $status_code );
$error_msg = isset( $data['error']['message'] )
? $data['error']['message']
: 'HTTP ' . $status_code;
// Log detailed diagnostic info for auth errors to aid debugging.
if ( 401 === $status_code || 403 === $status_code ) {
@@ -190,7 +254,22 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
}
// Success — parse response.
return $parser->parse_success( $data, $model );
if ( ! isset( $data['choices'][0]['message']['content'] ) ) {
throw new \RuntimeException(
__( 'OpenRouter returned unexpected response format.', 'prautoblogger' )
);
}
$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.
@@ -248,9 +327,9 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
/**
* Validate that the OpenRouter API key is configured and working.
*
* Delegates to PRAutoBlogger_OpenRouter_Validator for the actual checks.
* Makes a lightweight request to the /auth/key endpoint.
*
* Side effects: HTTP request to OpenRouter (via validator), logs diagnostic info.
* Side effects: HTTP request to OpenRouter, logs diagnostic info.
*
* @return bool
*/
@@ -262,16 +341,112 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
/**
* Validate credentials with detailed diagnostic info.
*
* Delegates to PRAutoBlogger_OpenRouter_Validator which returns an array
* with status ('ok' or 'error') and a human-readable message explaining
* what went wrong if validation fails.
* Returns an array with status ('ok' or 'error') and a human-readable
* message explaining what went wrong if validation fails.
*
* Side effects: HTTP request to OpenRouter (via validator), logs diagnostic info.
* Side effects: HTTP request to OpenRouter, logs diagnostic info.
*
* @return array{status: string, message: string, debug?: string}
*/
public function validate_credentials_detailed(): array {
return ( new PRAutoBlogger_OpenRouter_Validator() )->run();
$encrypted = get_option( 'prautoblogger_openrouter_api_key', '' );
if ( '' === $encrypted ) {
return [
'status' => 'error',
'message' => __( 'No API key saved. Enter your OpenRouter key in settings.', 'prautoblogger' ),
'debug' => 'option_empty',
];
}
$api_key = PRAutoBlogger_Encryption::decrypt( $encrypted );
if ( '' === $api_key ) {
return [
'status' => 'error',
'message' => __( 'API key decryption failed. Re-enter your key.', 'prautoblogger' ),
'debug' => 'decrypt_failed:encrypted_len=' . strlen( $encrypted ),
];
}
// Sanity check: OpenRouter keys typically start with "sk-or-".
$key_prefix = substr( $api_key, 0, 6 );
$key_len = strlen( $api_key );
if ( 0 !== strpos( $api_key, 'sk-or-' ) ) {
PRAutoBlogger_Logger::instance()->error(
sprintf(
'API key format invalid (prefix="%s", len=%d). Likely salt changed — re-enter key.',
$key_prefix,
$key_len
),
'openrouter'
);
return [
'status' => 'error',
'message' => __( 'API key appears corrupted (decryption produced invalid data). Your WordPress auth salt may have changed. Please re-enter your OpenRouter API key in settings.', 'prautoblogger' ),
'debug' => sprintf( 'bad_format:prefix=%s,len=%d', $key_prefix, $key_len ),
];
}
// Belt-and-suspenders: inject Authorization at cURL level (see send_chat_completion).
$base_url = $this->get_api_base_url();
$base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST );
$auth_header_value = 'Bearer ' . $api_key;
$curl_auth_filter_cred = function ( $handle, $parsed_args, $url ) use ( $auth_header_value, $base_host ): void {
if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) {
return;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
curl_setopt( $handle, CURLOPT_HTTPHEADER, [
'Authorization: ' . $auth_header_value,
] );
};
add_action( 'http_api_curl', $curl_auth_filter_cred, 99, 3 );
$response = wp_remote_get(
$base_url . '/auth/key',
[
'timeout' => 15,
'headers' => [
'Authorization' => 'Bearer ' . $api_key,
],
]
);
remove_action( 'http_api_curl', $curl_auth_filter_cred, 99 );
if ( is_wp_error( $response ) ) {
$err_msg = $response->get_error_message();
PRAutoBlogger_Logger::instance()->error(
sprintf( 'OpenRouter credential check wp_error: %s (key_prefix=%s, key_len=%d)', $err_msg, $key_prefix, $key_len ),
'openrouter'
);
return [
'status' => 'error',
'message' => sprintf( __( 'Network error reaching OpenRouter: %s', 'prautoblogger' ), $err_msg ),
'debug' => 'wp_error:' . $err_msg,
];
}
$status_code = wp_remote_retrieve_response_code( $response );
$body_raw = wp_remote_retrieve_body( $response );
if ( 200 === $status_code ) {
return [
'status' => 'ok',
'message' => __( 'OpenRouter connected.', 'prautoblogger' ),
];
}
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'OpenRouter credential check HTTP %d (key_prefix=%s, key_len=%d): %s', $status_code, $key_prefix, $key_len, substr( $body_raw, 0, 300 ) ),
'openrouter'
);
return [
'status' => 'error',
'message' => sprintf( __( 'OpenRouter returned HTTP %d. %s', 'prautoblogger' ), $status_code, substr( $body_raw, 0, 200 ) ),
'debug' => sprintf( 'http_%d:key_prefix=%s,key_len=%d', $status_code, $key_prefix, $key_len ),
];
}
/**

View File

@@ -1,79 +0,0 @@
<?php
declare(strict_types=1);
/**
* Request builder for OpenRouter API calls.
*
* Encapsulates the logic for building HTTP request headers, including
* Cloudflare AI Gateway caching directives and belt-and-suspenders
* cURL header injection to work around authorization header stripping
* in certain hosting environments.
*
* Triggered by: PRAutoBlogger_OpenRouter_Provider::send_chat_completion()
* Dependencies: add_action(), curl_setopt() (via http_api_curl filter).
*
* @see class-open-router-provider.php — Parent class that uses this builder.
*/
class PRAutoBlogger_OpenRouter_Request_Builder {
/**
* Build request headers for OpenRouter API call.
*
* Includes Authorization, Content-Type, HTTP-Referer, and optional
* Cloudflare AI Gateway cache control headers.
*
* @param string $api_key Decrypted OpenRouter API key.
* @param bool $via_gateway Whether the request is routed through Cloudflare AI Gateway.
* @param int $cache_ttl Cache TTL in seconds (0 disables caching).
*
* @return array<string, string> HTTP headers ready for wp_remote_post().
*/
public function build_headers( string $api_key, bool $via_gateway, int $cache_ttl ): array {
$headers = [
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json',
'HTTP-Referer' => home_url(),
'X-Title' => 'PRAutoBlogger WordPress Plugin',
];
if ( $via_gateway && $cache_ttl > 0 ) {
$headers['cf-aig-cache-ttl'] = (string) $cache_ttl;
}
return $headers;
}
/**
* Register a cURL filter to inject Authorization header.
*
* Some hosting environments (Hostinger, certain proxies) strip the
* Authorization header from wp_remote_post's 'headers' array before
* the request is sent. The http_api_curl action fires after WordPress
* configures the cURL handle but before curl_exec — setting
* CURLOPT_HTTPHEADER here ensures the header reaches the upstream.
*
* Side effects: Adds an http_api_curl filter (caller must remove).
*
* @param array $request_headers Request headers (includes Authorization).
* @param string $base_host Upstream host (scopes the filter to avoid leaking auth).
*
* @return callable The filter function (for later removal via remove_action).
*/
public function register_curl_auth_filter( array $request_headers, string $base_host ): callable {
$curl_auth_filter = function ( $handle, $parsed_args, $url ) use ( $request_headers, $base_host ): void {
// Scope to the configured upstream host only — never leak auth
// into unrelated outbound requests made elsewhere in WordPress.
if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) {
return;
}
$curl_headers = [];
foreach ( $request_headers as $name => $value ) {
$curl_headers[] = $name . ': ' . $value;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
curl_setopt( $handle, CURLOPT_HTTPHEADER, $curl_headers );
};
add_action( 'http_api_curl', $curl_auth_filter, 99, 3 );
return $curl_auth_filter;
}
}

View File

@@ -1,130 +0,0 @@
<?php
declare(strict_types=1);
/**
* Response parser for OpenRouter API responses.
*
* Encapsulates the logic for parsing and validating OpenRouter chat completion
* responses, including error classification and retry-decision logic.
*
* Triggered by: PRAutoBlogger_OpenRouter_Provider::send_chat_completion()
* Dependencies: PRAutoBlogger_Logger (for error/warning logging).
*
* @see class-open-router-provider.php — Parent class that uses this parser.
*/
class PRAutoBlogger_OpenRouter_Response_Parser {
/**
* Determine if an HTTP status code indicates a retryable error.
*
* Returns true if the failure is transient (5xx, 429) and the retry loop
* should continue with backoff. Returns false for 4xx errors, which should
* fail immediately without retry.
*
* @param int $status_code HTTP status code.
*
* @return bool
*/
public function is_retryable( int $status_code ): bool {
return 429 === $status_code || $status_code >= 500;
}
/**
* Parse a successful OpenRouter response.
*
* Validates the response contains the required fields and extracts
* the completion content and token usage.
*
* @param array $data Decoded JSON response from OpenRouter.
* @param string $model The model that was requested (fallback if not in response).
*
* @return array{
* content: string,
* model: string,
* prompt_tokens: int,
* completion_tokens: int,
* reasoning_tokens: int,
* total_tokens: int,
* finish_reason: string,
* reasoning_content: string,
* }
*
* @throws \RuntimeException If response structure is invalid.
*/
public function parse_success( array $data, string $model ): array {
// OpenRouter routers (e.g. openrouter/free) may wrap upstream provider
// errors inside an HTTP 200 body with an `error` object. Surface them
// as RuntimeExceptions so the retry loop or caller can handle them.
if ( isset( $data['error']['message'] ) ) {
$upstream_code = (int) ( $data['error']['code'] ?? 0 );
throw new \RuntimeException(
sprintf(
/* translators: %1$d: upstream HTTP code, %2$s: error message */
__( 'OpenRouter upstream error (code %1$d): %2$s', 'prautoblogger' ),
$upstream_code,
$data['error']['message']
)
);
}
// Validate we have a choices array with at least one message.
if ( ! isset( $data['choices'][0]['message'] ) ) {
// Log the raw shape so we can diagnose what the model actually returned.
PRAutoBlogger_Logger::instance()->error(
sprintf(
'Unexpected response shape from %s: %s',
$data['model'] ?? $model,
substr( (string) wp_json_encode( $data ), 0, 500 )
),
'openrouter'
);
throw new \RuntimeException(
sprintf(
/* translators: %s: model name */
__( 'OpenRouter model %s returned unexpected response format.', 'prautoblogger' ),
$data['model'] ?? $model
)
);
}
// Content can be null (some models use tool_calls or refusal instead).
// Treat null as empty string — downstream callers handle empty content.
$content = $data['choices'][0]['message']['content'] ?? '';
if ( null === $content ) {
$content = '';
}
$usage = $data['usage'] ?? [];
// Reasoning content lives alongside 'content' in the message object.
$reasoning_content = $data['choices'][0]['message']['reasoning'] ?? '';
return [
'content' => (string) $content,
'model' => $data['model'] ?? $model,
'prompt_tokens' => (int) ( $usage['prompt_tokens'] ?? 0 ),
'completion_tokens' => (int) ( $usage['completion_tokens'] ?? 0 ),
'reasoning_tokens' => (int) ( $usage['reasoning_tokens'] ?? 0 ),
'total_tokens' => (int) ( $usage['total_tokens'] ?? 0 ),
'finish_reason' => $data['choices'][0]['finish_reason'] ?? 'unknown',
'reasoning_content' => (string) $reasoning_content,
];
}
/**
* Extract the error message from a failed response.
*
* Prefers the `error.message` field if present; falls back to the
* HTTP status code as a string.
*
* @param array $data Decoded JSON response (may be empty or malformed).
* @param int $status HTTP status code.
*
* @return string Human-readable error message.
*/
public function get_error_message( array $data, int $status ): string {
return isset( $data['error']['message'] )
? $data['error']['message']
: 'HTTP ' . $status;
}
}

View File

@@ -1,152 +0,0 @@
<?php
declare(strict_types=1);
/**
* Credential + connectivity validator for the OpenRouter LLM provider.
*
* Split out of the provider to keep each file under the 300-line cap and
* to keep the validation path easy to unit-test without spinning up the
* retry loop. The provider's `validate_credentials_detailed()` delegates
* straight to `run()` here.
*
* Triggered by: PRAutoBlogger_OpenRouter_Provider::validate_credentials_detailed()
* which is called by the admin "Test Connection" action.
* Dependencies: PRAutoBlogger_OpenRouter_Config (URL resolution),
* PRAutoBlogger_Encryption (API key decryption),
* PRAutoBlogger_Logger (WARN/ERROR lines), wp_remote_get().
*
* @see class-open-router-provider.php Parent class that delegates here.
* @see class-open-router-config.php Config helpers for base URL.
* @see interface-llm-provider.php Return-shape contract.
*/
class PRAutoBlogger_OpenRouter_Validator {
private const TIMEOUT_SECONDS = 15;
/**
* Run a non-destructive credential check.
*
* Lightweight probe: hits the /auth/key endpoint. Never makes a real
* LLM completion request — that would consume tokens on every click.
*
* Side effects: one HTTP GET; one Logger line on failure.
*
* @return array{status: string, message: string, debug?: string}
*/
public function run(): array {
$encrypted = get_option( 'prautoblogger_openrouter_api_key', '' );
if ( '' === $encrypted ) {
return $this->err(
'option_empty',
__( 'No API key saved. Enter your OpenRouter key in settings.', 'prautoblogger' )
);
}
$api_key = PRAutoBlogger_Encryption::decrypt( $encrypted );
if ( '' === $api_key ) {
return $this->err(
'decrypt_failed:encrypted_len=' . strlen( $encrypted ),
__( 'API key decryption failed. Re-enter your key.', 'prautoblogger' )
);
}
// Sanity check: OpenRouter keys typically start with "sk-or-".
$key_prefix = substr( $api_key, 0, 6 );
$key_len = strlen( $api_key );
if ( 0 !== strpos( $api_key, 'sk-or-' ) ) {
PRAutoBlogger_Logger::instance()->error(
sprintf(
'API key format invalid (prefix="%s", len=%d). Likely salt changed — re-enter key.',
$key_prefix,
$key_len
),
'openrouter'
);
return $this->err(
sprintf( 'bad_format:prefix=%s,len=%d', $key_prefix, $key_len ),
__( 'API key appears corrupted (decryption produced invalid data). Your WordPress auth salt may have changed. Please re-enter your OpenRouter API key in settings.', 'prautoblogger' )
);
}
// Belt-and-suspenders: inject Authorization at cURL level (see send_chat_completion).
$config = new PRAutoBlogger_OpenRouter_Config();
$base_url = $config->get_api_base_url();
$base_host = (string) wp_parse_url( $base_url, PHP_URL_HOST );
$auth_header_value = 'Bearer ' . $api_key;
$curl_auth_filter_cred = function ( $handle, $parsed_args, $url ) use ( $auth_header_value, $base_host ): void {
if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) {
return;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
curl_setopt( $handle, CURLOPT_HTTPHEADER, [
'Authorization: ' . $auth_header_value,
] );
};
add_action( 'http_api_curl', $curl_auth_filter_cred, 99, 3 );
$response = wp_remote_get(
$base_url . '/auth/key',
[
'timeout' => self::TIMEOUT_SECONDS,
'headers' => [
'Authorization' => 'Bearer ' . $api_key,
],
]
);
remove_action( 'http_api_curl', $curl_auth_filter_cred, 99 );
if ( is_wp_error( $response ) ) {
$err_msg = $response->get_error_message();
PRAutoBlogger_Logger::instance()->error(
sprintf( 'OpenRouter credential check wp_error: %s (key_prefix=%s, key_len=%d)', $err_msg, $key_prefix, $key_len ),
'openrouter'
);
return $this->err(
'wp_error:' . $err_msg,
sprintf(
/* translators: %s: transport-layer error message. */
esc_html__( 'Network error reaching OpenRouter: %s', 'prautoblogger' ),
esc_html( $err_msg )
)
);
}
$status_code = wp_remote_retrieve_response_code( $response );
if ( 200 === $status_code ) {
return [
'status' => 'ok',
'message' => __( 'OpenRouter connected.', 'prautoblogger' ),
];
}
$body_raw = wp_remote_retrieve_body( $response );
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'OpenRouter credential check HTTP %d (key_prefix=%s, key_len=%d): %s', $status_code, $key_prefix, $key_len, substr( $body_raw, 0, 300 ) ),
'openrouter'
);
return $this->err(
sprintf( 'http_%d:key_prefix=%s,key_len=%d', $status_code, $key_prefix, $key_len ),
sprintf(
/* translators: %1$d: HTTP status, %2$s: response body. */
esc_html__( 'OpenRouter returned HTTP %1$d. %2$s', 'prautoblogger' ),
$status_code,
esc_html( substr( $body_raw, 0, 200 ) )
)
);
}
/**
* @return array{status: string, message: string, debug: string}
*/
private function err( string $debug, string $message ): array {
return [
'status' => 'error',
'message' => $message,
'debug' => $debug,
];
}
}

View File

@@ -1,196 +0,0 @@
<?php
declare(strict_types=1);
/**
* Reddit endpoint availability checker.
*
* Probes Reddit's RSS and .json endpoints to determine which data sources
* are reachable from the current server. Datacenter IPs (Hostinger) are
* often blocked by Reddit for .json but not RSS, so the client must check
* both before deciding which strategy to use.
*
* Triggered by: PRAutoBlogger_Reddit_JSON_Client::is_available() delegation,
* Admin settings page "test connection" button.
* Dependencies: wp_remote_get(), PRAutoBlogger_Logger.
*
* @see providers/class-reddit-json-client.php — Instantiates and delegates to this class.
* @see ARCHITECTURE.md — External API integrations table.
*/
class PRAutoBlogger_Reddit_Availability {
/**
* Reddit base URL for all endpoints.
*
* @var string
*/
private const BASE_URL = 'https://www.reddit.com';
/**
* User agent — Reddit requires a descriptive UA for unauthenticated requests.
*
* @var string
*/
private const USER_AGENT = 'Mozilla/5.0 (compatible; PRAutoBlogger/0.2.1; +https://peptiderepo.com)';
/**
* Transient key for rate limit tracking (.json endpoints only).
*
* @var string
*/
private const RATE_LIMIT_TRANSIENT = 'prautoblogger_reddit_json_rate_limit';
/**
* Check if any Reddit endpoint is reachable (RSS or .json).
*
* Side effects: HTTP request to Reddit.
*
* @return bool True if at least one endpoint is accessible.
*/
public function is_available(): bool {
return $this->is_rss_available() || $this->is_json_available();
}
/**
* Check if Reddit RSS endpoints are reachable.
*
* RSS endpoints are rarely blocked by Reddit, even for datacenter IPs.
*
* Side effects: One HTTP GET to Reddit.
*
* @return bool True if RSS endpoint returns HTTP 200.
*/
public function is_rss_available(): bool {
$url = self::BASE_URL . '/r/all/hot.rss?limit=1';
$response = wp_remote_get(
$url,
[
'timeout' => 10,
'headers' => [
'User-Agent' => self::USER_AGENT,
],
]
);
if ( is_wp_error( $response ) ) {
return false;
}
return 200 === wp_remote_retrieve_response_code( $response );
}
/**
* Check if Reddit .json endpoints are reachable.
*
* Datacenter IPs are often blocked by Reddit for .json endpoints.
*
* Side effects: One HTTP GET to Reddit.
*
* @return bool True if .json endpoint returns HTTP 200.
*/
public function is_json_available(): bool {
$url = self::BASE_URL . '/r/all/hot.json?limit=1';
$response = wp_remote_get(
$url,
[
'timeout' => 10,
'headers' => [
'User-Agent' => self::USER_AGENT,
],
]
);
if ( is_wp_error( $response ) ) {
return false;
}
return 200 === wp_remote_retrieve_response_code( $response );
}
/**
* Get current rate limit status from cached .json response headers.
*
* Reddit .json endpoints return rate limit headers. RSS has no rate limits.
* The rate data is written by api_get() and read here.
*
* @return array{remaining: int, limit: int, resets_at: string}
*/
public function get_rate_limit_status(): array {
$status = get_transient( self::RATE_LIMIT_TRANSIENT );
if ( is_array( $status ) ) {
return $status;
}
return [
'remaining' => 10,
'limit' => 10,
'resets_at' => '',
];
}
/**
* Make an HTTP GET request to a Reddit .json endpoint.
*
* No authentication — just a plain GET with a descriptive User-Agent.
* Tracks rate limits from response headers when available.
*
* Side effects: HTTP request, sets rate limit transient, logs errors.
*
* @param string $url Full URL with .json suffix and query params.
*
* @return array<string, mixed>|null Decoded JSON, or null on failure.
*/
public function api_get( string $url ): ?array {
$response = wp_remote_get(
$url,
[
'timeout' => 30,
'headers' => [
'User-Agent' => self::USER_AGENT,
'Accept' => 'application/json',
],
]
);
if ( is_wp_error( $response ) ) {
PRAutoBlogger_Logger::instance()->error(
'Reddit .json 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_used = wp_remote_retrieve_header( $response, 'x-ratelimit-used' );
$rate_reset = wp_remote_retrieve_header( $response, 'x-ratelimit-reset' );
if ( '' !== $rate_remaining ) {
set_transient(
self::RATE_LIMIT_TRANSIENT,
[
'remaining' => (int) $rate_remaining,
'limit' => (int) $rate_used + (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 ) {
PRAutoBlogger_Logger::instance()->error(
sprintf(
'Reddit .json HTTP %d: %s',
$status_code,
substr( wp_remote_retrieve_body( $response ), 0, 300 )
),
'reddit'
);
return null;
}
return json_decode( wp_remote_retrieve_body( $response ), true );
}
}

View File

@@ -10,12 +10,10 @@ declare(strict_types=1);
* for comment fetching (comments are not available in RSS feeds).
*
* Triggered by: PRAutoBlogger_Reddit_Provider::collect_data().
* Dependencies: wp_remote_get(), PRAutoBlogger_Logger, PRAutoBlogger_Reddit_RSS_Parser.
* Dependencies: wp_remote_get(), PRAutoBlogger_Logger.
*
* @see providers/class-reddit-provider.php — Instantiates and calls this class.
* @see providers/class-reddit-rss-parser.php — Parses Atom XML into post arrays.
* @see providers/class-reddit-availability.php — Endpoint health checks.
* @see ARCHITECTURE.md — External API integrations table.
* @see providers/class-reddit-provider.php — Instantiates and calls this class.
* @see ARCHITECTURE.md — External API integrations table.
*/
class PRAutoBlogger_Reddit_JSON_Client {
@@ -41,6 +39,13 @@ class PRAutoBlogger_Reddit_JSON_Client {
*/
private const TIMEOUT_SECONDS = 30;
/**
* Transient key for rate limit tracking (.json endpoints only).
*
* @var string
*/
private const RATE_LIMIT_TRANSIENT = 'prautoblogger_reddit_json_rate_limit';
/**
* Fetch hot posts from a subreddit. Tries RSS first, .json fallback.
*
@@ -124,8 +129,7 @@ class PRAutoBlogger_Reddit_JSON_Client {
}
$body = wp_remote_retrieve_body( $response );
$parser = new PRAutoBlogger_Reddit_RSS_Parser();
return $parser->parse( $body, $subreddit );
return $this->parse_atom_feed( $body, $subreddit );
}
/**
@@ -169,6 +173,105 @@ class PRAutoBlogger_Reddit_JSON_Client {
return $posts;
}
/**
* Parse a Reddit Atom feed into post-data arrays matching .json format.
*
* Maps Atom entry fields to the standard Reddit post fields so the rest
* of the pipeline can consume RSS data without changes. Fields unavailable
* in RSS (score, num_comments, upvote_ratio) are set to reasonable defaults.
*
* @param string $xml Raw Atom XML string.
* @param string $subreddit The subreddit name for context.
*
* @return array<int, array<string, mixed>> Parsed post data.
*/
private function parse_atom_feed( string $xml, string $subreddit ): array {
// Suppress XML errors for malformed feeds.
$prev_errors = libxml_use_internal_errors( true );
$feed = simplexml_load_string( $xml );
libxml_use_internal_errors( $prev_errors );
if ( false === $feed ) {
PRAutoBlogger_Logger::instance()->error( 'Failed to parse Reddit RSS XML.', 'reddit' );
return [];
}
// Register the Atom namespace.
$feed->registerXPathNamespace( 'atom', 'http://www.w3.org/2005/Atom' );
$entries = $feed->xpath( '//atom:entry' );
if ( empty( $entries ) ) {
return [];
}
$posts = [];
foreach ( $entries as $entry ) {
$title = (string) $entry->title;
$link = '';
$content = '';
// Get the HTML link (type="text/html").
foreach ( $entry->link as $link_el ) {
if ( 'text/html' === (string) $link_el['type'] || 'alternate' === (string) $link_el['rel'] ) {
$link = (string) $link_el['href'];
break;
}
}
// Content is in <content> tag as HTML.
if ( isset( $entry->content ) ) {
$content = wp_strip_all_tags( (string) $entry->content );
}
// Extract post ID from the entry id (format: /r/subreddit/comments/ID/...).
$entry_id = (string) $entry->id;
$post_id = '';
if ( preg_match( '#/comments/([a-z0-9]+)#', $entry_id, $m ) ) {
$post_id = $m[1];
}
// Extract author name.
$author = '[deleted]';
if ( isset( $entry->author->name ) ) {
$author = str_replace( '/u/', '', (string) $entry->author->name );
}
// Parse published date to Unix timestamp.
$published = (string) ( $entry->published ?? $entry->updated ?? '' );
$created_utc = '' !== $published ? (int) strtotime( $published ) : time();
// Extract permalink (relative path) from full URL.
$permalink = '';
if ( '' !== $link ) {
$parsed = wp_parse_url( $link );
$permalink = $parsed['path'] ?? '';
}
$posts[] = [
'id' => $post_id,
'title' => $title,
'selftext' => $content,
'author' => $author,
'score' => 1, // Not available in RSS.
'num_comments' => 0, // Not available in RSS.
'permalink' => $permalink,
'created_utc' => $created_utc,
'is_self' => true,
'link_flair_text' => null,
'upvote_ratio' => null,
'is_original_content' => false,
'data_source' => 'reddit_rss',
];
}
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Parsed %d posts from Reddit RSS for r/%s.', count( $posts ), $subreddit ),
'reddit'
);
return $posts;
}
/**
* Fetch top comments for a specific post via Reddit's .json endpoint.
*
@@ -221,47 +324,145 @@ class PRAutoBlogger_Reddit_JSON_Client {
* @return bool True if we got a valid response from either endpoint.
*/
public function is_available(): bool {
return ( new PRAutoBlogger_Reddit_Availability() )->is_available();
return $this->is_rss_available() || $this->is_json_available();
}
/**
* Check if Reddit RSS endpoints are reachable.
*
* RSS endpoints are rarely blocked by Reddit, even for datacenter IPs.
*
* @return bool True if RSS endpoint returns HTTP 200.
*/
public function is_rss_available(): bool {
return ( new PRAutoBlogger_Reddit_Availability() )->is_rss_available();
$url = self::BASE_URL . '/r/all/hot.rss?limit=1';
$response = wp_remote_get(
$url,
[
'timeout' => 10,
'headers' => [
'User-Agent' => self::USER_AGENT,
],
]
);
if ( is_wp_error( $response ) ) {
return false;
}
return 200 === wp_remote_retrieve_response_code( $response );
}
/**
* Check if Reddit .json endpoints are reachable.
*
* Datacenter IPs are often blocked by Reddit for .json endpoints.
*
* @return bool True if .json endpoint returns HTTP 200.
*/
public function is_json_available(): bool {
return ( new PRAutoBlogger_Reddit_Availability() )->is_json_available();
$url = self::BASE_URL . '/r/all/hot.json?limit=1';
$response = wp_remote_get(
$url,
[
'timeout' => 10,
'headers' => [
'User-Agent' => self::USER_AGENT,
],
]
);
if ( is_wp_error( $response ) ) {
return false;
}
return 200 === wp_remote_retrieve_response_code( $response );
}
/**
* Get current rate limit status.
*
* Reddit .json endpoints return rate limit headers. RSS has no rate limits.
*
* @return array{remaining: int, limit: int, resets_at: string}
*/
public function get_rate_limit_status(): array {
return ( new PRAutoBlogger_Reddit_Availability() )->get_rate_limit_status();
$status = get_transient( self::RATE_LIMIT_TRANSIENT );
if ( is_array( $status ) ) {
return $status;
}
return [
'remaining' => 10,
'limit' => 10,
'resets_at' => '',
];
}
/**
* Make an HTTP GET request to a Reddit .json endpoint.
*
* Delegates to PRAutoBlogger_Reddit_Availability which owns the HTTP
* transport layer and rate-limit tracking.
* No authentication — just a plain GET with a descriptive User-Agent.
* Tracks rate limits from response headers when available.
*
* Side effects: HTTP request, sets rate limit transient, logs errors.
*
* @param string $url Full URL with .json suffix and query params.
*
* @return array<string, mixed>|null Decoded JSON, or null on failure.
*/
private function api_get( string $url ): ?array {
return ( new PRAutoBlogger_Reddit_Availability() )->api_get( $url );
$response = wp_remote_get(
$url,
[
'timeout' => self::TIMEOUT_SECONDS,
'headers' => [
'User-Agent' => self::USER_AGENT,
'Accept' => 'application/json',
],
]
);
if ( is_wp_error( $response ) ) {
PRAutoBlogger_Logger::instance()->error(
'Reddit .json 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_used = wp_remote_retrieve_header( $response, 'x-ratelimit-used' );
$rate_reset = wp_remote_retrieve_header( $response, 'x-ratelimit-reset' );
if ( '' !== $rate_remaining ) {
set_transient(
self::RATE_LIMIT_TRANSIENT,
[
'remaining' => (int) $rate_remaining,
'limit' => (int) $rate_used + (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 ) {
PRAutoBlogger_Logger::instance()->error(
sprintf(
'Reddit .json HTTP %d: %s',
$status_code,
substr( wp_remote_retrieve_body( $response ), 0, 300 )
),
'reddit'
);
return null;
}
return json_decode( wp_remote_retrieve_body( $response ), true );
}
}

View File

@@ -128,11 +128,10 @@ class PRAutoBlogger_Reddit_Provider implements PRAutoBlogger_Source_Provider_Int
sprintf( 'Reddit r/%s: fetched %d items', $subreddit, $added ),
'reddit'
);
} catch ( \Throwable $e ) {
} catch ( \Exception $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf(
'Reddit: %s collecting from r/%s: %s',
get_class( $e ),
'Reddit: Failed to collect from r/%s: %s',
$subreddit,
$e->getMessage()
),

View File

@@ -1,137 +0,0 @@
<?php
declare(strict_types=1);
/**
* Reddit RSS/Atom feed parser — parses Atom XML into normalized post arrays.
*
* Handles parsing of Reddit's Atom/RSS feeds and mapping them to the standard
* Reddit post data format (same shape as .json responses). This parser is
* self-contained: it takes raw XML and produces normalized arrays.
*
* Fields unavailable in RSS feeds (score, num_comments, upvote_ratio) are set
* to sensible defaults so the rest of the pipeline can treat RSS and .json data uniformly.
*
* Usage: Called by PRAutoBlogger_Reddit_JSON_Client::fetch_posts_via_rss()
* to parse the response body.
*
* Dependencies: wp_strip_all_tags(), PRAutoBlogger_Logger, built-in SimpleXML.
*
* @see providers/class-reddit-json-client.php — Main client that calls this parser.
* @see ARCHITECTURE.md — External API integrations table.
*/
class PRAutoBlogger_Reddit_RSS_Parser {
/**
* Parse a Reddit Atom feed into post-data arrays matching .json format.
*
* Maps Atom entry fields to the standard Reddit post fields so the rest
* of the pipeline can consume RSS data without changes. Fields unavailable
* in RSS (score, num_comments, upvote_ratio) are set to reasonable defaults.
*
* @param string $xml Raw Atom XML string.
* @param string $subreddit The subreddit name for context (logging only).
*
* @return array<int, array<string, mixed>> Parsed post data.
*/
public function parse( string $xml, string $subreddit ): array {
// Suppress XML errors for malformed feeds.
$prev_errors = libxml_use_internal_errors( true );
$feed = simplexml_load_string( $xml );
libxml_use_internal_errors( $prev_errors );
if ( false === $feed ) {
PRAutoBlogger_Logger::instance()->error( 'Failed to parse Reddit RSS XML.', 'reddit' );
return [];
}
// Register the Atom namespace.
$feed->registerXPathNamespace( 'atom', 'http://www.w3.org/2005/Atom' );
$entries = $feed->xpath( '//atom:entry' );
if ( empty( $entries ) ) {
return [];
}
$posts = [];
foreach ( $entries as $entry ) {
$post = $this->parse_entry( $entry, $subreddit );
if ( null !== $post ) {
$posts[] = $post;
}
}
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Parsed %d posts from Reddit RSS for r/%s.', count( $posts ), $subreddit ),
'reddit'
);
return $posts;
}
/**
* Parse a single Atom entry element into a normalized post array.
*
* @param \SimpleXMLElement $entry An Atom entry element.
* @param string $subreddit The subreddit name (for context/logging).
*
* @return array<string, mixed>|null The parsed post data, or null if parsing fails.
*/
private function parse_entry( $entry, string $subreddit ): ?array {
$title = (string) $entry->title;
$link = '';
$content = '';
// Get the HTML link (type="text/html").
foreach ( $entry->link as $link_el ) {
if ( 'text/html' === (string) $link_el['type'] || 'alternate' === (string) $link_el['rel'] ) {
$link = (string) $link_el['href'];
break;
}
}
// Content is in <content> tag as HTML.
if ( isset( $entry->content ) ) {
$content = wp_strip_all_tags( (string) $entry->content );
}
// Extract post ID from the entry id (format: /r/subreddit/comments/ID/...).
$entry_id = (string) $entry->id;
$post_id = '';
if ( preg_match( '#/comments/([a-z0-9]+)#', $entry_id, $m ) ) {
$post_id = $m[1];
}
// Extract author name.
$author = '[deleted]';
if ( isset( $entry->author->name ) ) {
$author = str_replace( '/u/', '', (string) $entry->author->name );
}
// Parse published date to Unix timestamp.
$published = (string) ( $entry->published ?? $entry->updated ?? '' );
$created_utc = '' !== $published ? (int) strtotime( $published ) : time();
// Extract permalink (relative path) from full URL.
$permalink = '';
if ( '' !== $link ) {
$parsed = wp_parse_url( $link );
$permalink = $parsed['path'] ?? '';
}
return [
'id' => $post_id,
'title' => $title,
'selftext' => $content,
'author' => $author,
'score' => 1, // Not available in RSS.
'num_comments' => 0, // Not available in RSS.
'permalink' => $permalink,
'created_utc' => $created_utc,
'is_self' => true,
'link_flair_text' => null,
'upvote_ratio' => null,
'is_original_content' => false,
'data_source' => 'reddit_rss',
];
}
}

View File

@@ -1,294 +0,0 @@
<?php
declare(strict_types=1);
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_init
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_close
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_errno
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_error
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_getinfo
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_init
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_add_handle
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_exec
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_select
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_getcontent
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_remove_handle
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_close
/**
* Parallel image generation via curl_multi for Runware.
*
* Fires multiple POST /v1 inference calls concurrently, then fetches
* the returned signed image URLs in a second parallel round. Cuts
* wall-clock time from N×2s to ~2s regardless of batch size — matches
* the Image A/B parallelism pattern already in use for OpenRouter.
*
* Triggered by: PRAutoBlogger_Runware_Image_Provider::generate_image_batch().
* Dependencies: PRAutoBlogger_Runware_Image_Support, PRAutoBlogger_Runware_Image_Pricing,
* PRAutoBlogger_Logger.
*
* @see class-runware-image-provider.php — Sole caller.
* @see class-runware-image-support.php — Response parsing + image URL fetch.
* @see class-open-router-image-batch.php — Sibling pattern for OpenRouter.
*/
class PRAutoBlogger_Runware_Image_Batch {
/** @var PRAutoBlogger_Runware_Image_Pricing */
private PRAutoBlogger_Runware_Image_Pricing $pricing;
/** @var PRAutoBlogger_Runware_Image_Support */
private PRAutoBlogger_Runware_Image_Support $support;
public function __construct(
PRAutoBlogger_Runware_Image_Pricing $pricing,
PRAutoBlogger_Runware_Image_Support $support
) {
$this->pricing = $pricing;
$this->support = $support;
}
/**
* Execute multiple image generation requests concurrently via curl_multi.
*
* Two parallel rounds: (1) POST /v1 returns signed imageURL per request;
* (2) GET each signed imageURL returns PNG bytes. Each round runs
* concurrently. No retry — single-attempt success rate on schnell >99%.
*
* @param array<string, array{prompt: string, width: int, height: int, options?: array}> $requests Keyed requests.
* @return array<string, array> Image data or `{error}` keyed identically.
*/
public function execute( array $requests ): array {
if ( empty( $requests ) ) {
return [];
}
$api_key = $this->support->get_api_key();
$round1 = $this->round_one_inference( $api_key, $requests );
$results = [];
$to_download = [];
foreach ( $round1 as $key => $entry ) {
if ( isset( $entry['error'] ) ) {
$results[ $key ] = [ 'error' => $entry['error'] ];
continue;
}
$to_download[ $key ] = $entry;
}
if ( empty( $to_download ) ) {
return $results;
}
$bytes_map = $this->round_two_download( $to_download );
foreach ( $to_download as $key => $entry ) {
if ( isset( $bytes_map[ $key ]['error'] ) ) {
$results[ $key ] = [ 'error' => $bytes_map[ $key ]['error'] ];
continue;
}
$results[ $key ] = [
'bytes' => $bytes_map[ $key ]['bytes'],
'mime_type' => 'image/png',
'width' => $entry['width'],
'height' => $entry['height'],
'model' => $entry['model'],
'seed' => $entry['seed'],
'cost_usd' => $this->pricing->estimate_cost( $entry['width'], $entry['height'], $entry['model'] ),
'latency_ms' => (int) ( ( microtime( true ) - $entry['started_at'] ) * 1000 ),
];
}
return $results;
}
/**
* Round 1: fire all inference POSTs in parallel.
*
* @param string $api_key Plaintext API key.
* @param array $requests Batch requests.
* @return array<string, array> Map of key => {error} | {image_url, width, height, model, seed, started_at}.
*/
private function round_one_inference( string $api_key, array $requests ): array {
$headers = [ 'Content-Type: application/json' ];
$mh = curl_multi_init();
$handles = [];
$start_times = [];
foreach ( $requests as $key => $req ) {
$model = $this->pricing->resolve_model( (string) ( $req['options']['model'] ?? '' ) );
$snap_w = $this->support->snap_dimension( (int) $req['width'] );
$snap_h = $this->support->snap_dimension( (int) $req['height'] );
$seed = $this->support->normalize_seed( isset( $req['options']['seed'] ) ? (int) $req['options']['seed'] : null );
$steps = isset( $req['options']['steps'] ) ? (int) $req['options']['steps'] : $this->pricing->default_steps( $model );
$ch = curl_init();
curl_setopt( $ch, CURLOPT_URL, PRAutoBlogger_Runware_Image_Support::API_URL );
curl_setopt( $ch, CURLOPT_POST, true );
curl_setopt( $ch, CURLOPT_POSTFIELDS, wp_json_encode(
$this->build_payload( $api_key, (string) $req['prompt'], $snap_w, $snap_h, $model, $seed, $steps )
) );
curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_TIMEOUT, PRAUTOBLOGGER_API_TIMEOUT_SECONDS );
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
curl_multi_add_handle( $mh, $ch );
$handles[ $key ] = [
'handle' => $ch,
'model' => $model,
'width' => $snap_w,
'height' => $snap_h,
'seed' => $seed,
];
$start_times[ $key ] = microtime( true );
}
$this->run_multi( $mh );
$out = [];
foreach ( $handles as $key => $entry ) {
$out[ $key ] = $this->collect_inference_result( (string) $key, $entry, $start_times[ $key ] );
curl_multi_remove_handle( $mh, $entry['handle'] );
curl_close( $entry['handle'] );
}
curl_multi_close( $mh );
return $out;
}
/**
* Round 2: fetch all signed image URLs in parallel.
*
* @param array<string, array> $inferences Map from round one.
* @return array<string, array{bytes?: string, error?: string}>
*/
private function round_two_download( array $inferences ): array {
$mh = curl_multi_init();
$handles = [];
foreach ( $inferences as $key => $entry ) {
$ch = curl_init();
curl_setopt( $ch, CURLOPT_URL, $entry['image_url'] );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_TIMEOUT, 30 );
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
curl_multi_add_handle( $mh, $ch );
$handles[ $key ] = $ch;
}
$this->run_multi( $mh );
$out = [];
foreach ( $handles as $key => $ch ) {
$errno = curl_errno( $ch );
if ( 0 !== $errno ) {
$msg = sprintf( 'Runware image download cURL error %d for "%s": %s', $errno, $key, curl_error( $ch ) );
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
$out[ $key ] = [ 'error' => $msg ];
} else {
$code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
$body = (string) curl_multi_getcontent( $ch );
if ( 200 !== $code || '' === $body ) {
$msg = sprintf( 'Runware image download HTTP %d for "%s" (%d bytes)', $code, $key, strlen( $body ) );
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
$out[ $key ] = [ 'error' => $msg ];
} else {
$out[ $key ] = [ 'bytes' => $body ];
}
}
curl_multi_remove_handle( $mh, $ch );
curl_close( $ch );
}
curl_multi_close( $mh );
return $out;
}
/**
* Drive curl_multi_exec to completion.
*
* @param \CurlMultiHandle $mh Multi handle.
*/
private function run_multi( $mh ): void {
$running = 0;
do {
$status = curl_multi_exec( $mh, $running );
if ( $running > 0 ) {
curl_multi_select( $mh, 1.0 );
}
} while ( $running > 0 && CURLM_OK === $status );
}
/**
* Build the auth+inference task-array body for one image request.
*
* @param string $api_key Plaintext API key.
* @param string $prompt Prompt text.
* @param int $width Snapped width.
* @param int $height Snapped height.
* @param string $model Resolved model id.
* @param int $seed Positive seed.
* @param int $steps Inference steps.
* @return array<int, array<string, mixed>>
*/
private function build_payload( string $api_key, string $prompt, int $width, int $height, string $model, int $seed, int $steps ): array {
return [
[ 'taskType' => 'authentication', 'apiKey' => $api_key ],
[
'taskType' => 'imageInference',
'taskUUID' => wp_generate_uuid4(),
'positivePrompt' => $prompt,
'width' => $width,
'height' => $height,
'model' => $model,
'steps' => $steps,
'numberResults' => 1,
'outputFormat' => 'PNG',
'outputType' => 'URL',
'seed' => $seed,
],
];
}
/**
* Parse one round-1 handle into either an error or an imageURL entry.
*
* @param string $key Request key.
* @param array $entry Handle entry (handle + model + dims + seed).
* @param float $start Microtime when this handle was queued.
* @return array Either {error} or {image_url, width, height, model, seed, started_at}.
*/
private function collect_inference_result( string $key, array $entry, float $start ): array {
$ch = $entry['handle'];
$errno = curl_errno( $ch );
if ( 0 !== $errno ) {
$msg = sprintf( 'Runware batch inference cURL error %d for "%s": %s', $errno, $key, curl_error( $ch ) );
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
return [ 'error' => $msg ];
}
$http_code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
$raw = (string) curl_multi_getcontent( $ch );
if ( $http_code >= 400 ) {
$msg = sprintf( 'Runware batch inference HTTP %d for "%s": %s', $http_code, $key, substr( $raw, 0, 300 ) );
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
return [ 'error' => $msg ];
}
try {
$image_url = $this->support->extract_image_url( $raw );
} catch ( \Throwable $e ) {
$msg = sprintf( 'Runware batch inference parse error for "%s": %s', $key, $e->getMessage() );
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
return [ 'error' => $msg ];
}
return [
'image_url' => $image_url,
'width' => (int) $entry['width'],
'height' => (int) $entry['height'],
'model' => (string) $entry['model'],
'seed' => (int) $entry['seed'],
'started_at' => $start,
];
}
}

View File

@@ -1,118 +0,0 @@
<?php
declare(strict_types=1);
/**
* Pricing + model-id resolution helpers for the Runware image provider.
*
* Each Runware FLUX model has a flat per-image cost sourced from
* runware.ai/pricing (April 2026). Unlike OpenRouter models, Runware
* prices scale slightly with steps — the quoted rate here assumes the
* provider's default step count (4 for schnell, 28 for dev).
*
* Triggered by: PRAutoBlogger_Runware_Image_Provider, PRAutoBlogger_Runware_Image_Batch.
* Dependencies: None — pure calculation + option reads.
*
* @see class-runware-image-provider.php — Primary caller.
* @see class-open-router-image-pricing.php — Sibling pattern for OpenRouter.
*/
class PRAutoBlogger_Runware_Image_Pricing {
/**
* Flat per-image cost (USD) for each supported Runware model, April 2026.
* Update here when Runware reprices or new models launch.
*
* Runware model IDs use `family:variant@version` form. The two FLUX
* variants we support are the CEO-sanctioned defaults.
*
* @var array<string, float>
*/
private const COST_PER_IMAGE = [
// FLUX.1 schnell — 4-step distilled. Our new default (2026-04-21).
'runware:100@1' => 0.0006,
// FLUX.1 dev — 28-step full model. Higher fidelity, ~30x cost.
'runware:101@1' => 0.02,
];
/**
* Default number of inference steps per model. Runware bills per image
* at the model's standard step count; custom step counts may alter price.
*
* @var array<string, int>
*/
private const DEFAULT_STEPS = [
'runware:100@1' => 4,
'runware:101@1' => 28,
];
/**
* Resolve the model identifier from a hint, falling back to site option,
* then to the PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL constant.
*
* If the saved/default model is not a Runware id (e.g. still points at
* a Gemini slug), fall back to schnell — the provider was selected
* explicitly, so the user wants a Runware model.
*
* @param string $hint Caller-supplied model identifier (may be empty).
* @return string Runware model id.
*/
public function resolve_model( string $hint ): string {
$candidate = trim( $hint );
if ( '' === $candidate ) {
$candidate = trim( (string) get_option( 'prautoblogger_image_model', '' ) );
}
if ( '' === $candidate && defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) {
$candidate = (string) PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL;
}
// If the candidate isn't a known Runware id, fall back to schnell.
if ( '' === $candidate || ! isset( self::COST_PER_IMAGE[ $candidate ] ) ) {
return 'runware:100@1';
}
return $candidate;
}
/**
* Estimate USD cost for a single image at the given model.
*
* Runware FLUX pricing is flat per image at the model's standard step
* count. Width/height are accepted for interface compatibility but do
* not affect cost for the variants we support.
*
* @param int $width Width in pixels (unused for flat pricing).
* @param int $height Height in pixels (unused for flat pricing).
* @param string $model Resolved Runware model id.
* @return float USD cost rounded to 6 decimals.
*/
public function estimate_cost( int $width, int $height, string $model ): float {
if ( isset( self::COST_PER_IMAGE[ $model ] ) ) {
return round( self::COST_PER_IMAGE[ $model ], 6 );
}
// Unknown model — log it and use a conservative $0.01 estimate.
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'No Runware pricing data for model "%s". Using $0.01 estimate.', $model ),
'runware-image'
);
return 0.01;
}
/**
* Get the default step count for a Runware model.
*
* @param string $model Resolved Runware model id.
* @return int Default steps.
*/
public function default_steps( string $model ): int {
return self::DEFAULT_STEPS[ $model ] ?? 4;
}
/**
* Get the full model cost table for admin display.
*
* @return array<string, float> Model id => cost per image in USD.
*/
public static function get_model_costs(): array {
return self::COST_PER_IMAGE;
}
}

View File

@@ -1,294 +0,0 @@
<?php
declare(strict_types=1);
/**
* Runware image provider — FLUX.1 schnell / dev via runware.ai.
*
* Default image backend as of 2026-04-21: schnell (runware:100@1) at
* ~$0.0006/image, ~2s latency. 65× cheaper than Gemini Nano Banana with
* quality that reads as "stylistic looseness" for our comic aesthetic.
*
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation.
* Dependencies: Runware_Image_Support (key/parsing/backoff),
* Runware_Image_Pricing (model/cost),
* Runware_Image_Batch (parallel curl_multi), Logger.
*
* @see interface-image-provider.php — Contract.
* @see class-runware-image-batch.php — Parallel generation via curl_multi.
* @see class-runware-image-support.php — Key, response parsing, retry.
* @see class-runware-image-pricing.php — Model + cost table.
* @see class-open-router-image-provider.php — Sibling pattern for OpenRouter.
* @see ARCHITECTURE.md — External API Integrations table.
*/
class PRAutoBlogger_Runware_Image_Provider implements PRAutoBlogger_Image_Provider_Interface {
private const PROVIDER_ID = 'runware';
private const MIN_DIMENSION_PX = 64;
private const MAX_DIMENSION_PX = 2048;
/** @var PRAutoBlogger_Runware_Image_Pricing */
private PRAutoBlogger_Runware_Image_Pricing $pricing;
/** @var PRAutoBlogger_Runware_Image_Support */
private PRAutoBlogger_Runware_Image_Support $support;
public function __construct() {
$this->pricing = new PRAutoBlogger_Runware_Image_Pricing();
$this->support = new PRAutoBlogger_Runware_Image_Support();
}
/**
* {@inheritDoc}
*
* Two-step HTTP flow: POST /v1 returns a signed imageURL, then GET that
* URL for the bytes. Retries 5xx/429 on the POST with exponential
* backoff; 4xx (except 429) fail immediately. The image download GET
* is not retried — signed URLs are short-lived.
*/
public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array {
$this->assert_valid_dimensions( $width, $height );
$prompt = trim( $prompt );
if ( '' === $prompt ) {
throw new \InvalidArgumentException(
esc_html__( 'Image prompt cannot be empty.', 'prautoblogger' )
);
}
$api_key = $this->support->get_api_key();
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
$snap_w = $this->support->snap_dimension( $width );
$snap_h = $this->support->snap_dimension( $height );
$seed = $this->support->normalize_seed( isset( $options['seed'] ) ? (int) $options['seed'] : null );
$steps = isset( $options['steps'] ) ? (int) $options['steps'] : $this->pricing->default_steps( $model );
$body = $this->build_request_body( $api_key, $prompt, $snap_w, $snap_h, $model, $seed, $steps );
$headers = [ 'Content-Type' => 'application/json' ];
$started_at = microtime( true );
$last_error = '';
for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) {
$response = wp_remote_post( PRAutoBlogger_Runware_Image_Support::API_URL, [
'timeout' => PRAUTOBLOGGER_API_TIMEOUT_SECONDS,
'headers' => $headers,
'body' => wp_json_encode( $body ),
] );
if ( is_wp_error( $response ) ) {
$last_error = $response->get_error_message();
$this->support->log_retry( $attempt, 'network', $last_error );
$this->support->backoff( $attempt );
continue;
}
$status = (int) wp_remote_retrieve_response_code( $response );
$raw = (string) wp_remote_retrieve_body( $response );
if ( 429 === $status || $status >= 500 ) {
$last_error = sprintf( 'HTTP %d: %s', $status, substr( $raw, 0, 300 ) );
$this->support->log_retry( $attempt, (string) $status, $last_error );
if ( $attempt < PRAUTOBLOGGER_MAX_RETRIES ) {
$retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
$this->support->backoff( $attempt, $retry_after );
}
continue;
}
if ( $status >= 400 ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Runware image gen HTTP %d: %s', $status, substr( $raw, 0, 300 ) ),
'runware-image'
);
throw new \RuntimeException(
sprintf(
esc_html__( 'Runware image error (HTTP %1$d): %2$s', 'prautoblogger' ),
$status,
esc_html( substr( $raw, 0, 300 ) )
)
);
}
$image_url = $this->support->extract_image_url( $raw );
$image_bytes = $this->support->download_image_bytes( $image_url );
return [
'bytes' => $image_bytes,
'mime_type' => 'image/png',
'width' => $snap_w,
'height' => $snap_h,
'model' => $model,
'seed' => $seed,
'cost_usd' => $this->pricing->estimate_cost( $snap_w, $snap_h, $model ),
'latency_ms' => (int) ( ( microtime( true ) - $started_at ) * 1000 ),
];
}
throw new \RuntimeException(
sprintf(
esc_html__( 'Runware image gen failed after %1$d attempts. Last error: %2$s', 'prautoblogger' ),
PRAUTOBLOGGER_MAX_RETRIES,
esc_html( $last_error )
)
);
}
/**
* Generate multiple images concurrently via curl_multi.
*
* Delegates to PRAutoBlogger_Runware_Image_Batch, which fires all
* inference POSTs in parallel, then fetches the signed image URLs
* in parallel as well. Wall-clock time ≈ slowest single image.
*
* {@inheritDoc}
*/
public function generate_image_batch( array $requests ): array {
foreach ( $requests as $key => $req ) {
$this->assert_valid_dimensions( $req['width'], $req['height'] );
if ( '' === trim( $req['prompt'] ) ) {
throw new \InvalidArgumentException(
sprintf( 'Image prompt for key "%s" cannot be empty.', esc_html( $key ) )
);
}
}
$batch = new PRAutoBlogger_Runware_Image_Batch( $this->pricing, $this->support );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Starting parallel Runware image batch (%d images)', count( $requests ) ),
'runware-image'
);
return $batch->execute( $requests );
}
/** {@inheritDoc} */
public function estimate_cost( int $width, int $height, array $options = [] ): float {
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
return $this->pricing->estimate_cost( $width, $height, $model );
}
/** {@inheritDoc} */
public function get_provider_name(): string {
return self::PROVIDER_ID;
}
/**
* {@inheritDoc}
*
* Runware has no dedicated "validate key" endpoint, so we send a minimal
* authentication task and look for `taskType=authentication` in the
* response data with no accompanying errors.
*/
public function validate_credentials_detailed(): array {
try {
$api_key = $this->support->get_api_key();
} catch ( \RuntimeException $e ) {
return [ 'status' => 'error', 'message' => $e->getMessage() ];
}
$body = [
[ 'taskType' => 'authentication', 'apiKey' => $api_key ],
];
$response = wp_remote_post( PRAutoBlogger_Runware_Image_Support::API_URL, [
'timeout' => 15,
'headers' => [ 'Content-Type' => 'application/json' ],
'body' => wp_json_encode( $body ),
] );
if ( is_wp_error( $response ) ) {
return [
'status' => 'error',
'message' => __( 'Could not reach Runware API.', 'prautoblogger' ),
'debug' => $response->get_error_message(),
];
}
$status = (int) wp_remote_retrieve_response_code( $response );
$raw = (string) wp_remote_retrieve_body( $response );
if ( 200 !== $status ) {
return [
'status' => 'error',
'message' => sprintf( __( 'Runware returned HTTP %d.', 'prautoblogger' ), $status ),
'debug' => substr( $raw, 0, 200 ),
];
}
$decoded = json_decode( $raw, true );
if ( is_array( $decoded ) && isset( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
$first = $decoded['errors'][0] ?? [];
$msg = is_array( $first ) ? (string) ( $first['message'] ?? '' ) : (string) $first;
return [
'status' => 'error',
'message' => __( 'Runware rejected the API key.', 'prautoblogger' ),
'debug' => substr( $msg, 0, 200 ),
];
}
return [ 'status' => 'ok', 'message' => __( 'Runware API key valid.', 'prautoblogger' ) ];
}
/**
* Build the JSON task-array body for a single imageInference request.
*
* Runware's single endpoint consumes [auth, inference] in one POST.
*
* @param string $api_key Plaintext API key.
* @param string $prompt Full prompt text.
* @param int $width Snapped width.
* @param int $height Snapped height.
* @param string $model Resolved model id.
* @param int $seed Positive seed.
* @param int $steps Inference steps.
* @return array<int, array<string, mixed>>
*/
private function build_request_body(
string $api_key,
string $prompt,
int $width,
int $height,
string $model,
int $seed,
int $steps
): array {
return [
[
'taskType' => 'authentication',
'apiKey' => $api_key,
],
[
'taskType' => 'imageInference',
'taskUUID' => wp_generate_uuid4(),
'positivePrompt' => $prompt,
'width' => $width,
'height' => $height,
'model' => $model,
'steps' => $steps,
'numberResults' => 1,
'outputFormat' => 'PNG',
'outputType' => 'URL',
'seed' => $seed,
],
];
}
/**
* Enforce the interface's dimension contract.
*
* @param int $width Width in pixels.
* @param int $height Height in pixels.
* @throws \InvalidArgumentException If outside [MIN, MAX].
*/
private function assert_valid_dimensions( int $width, int $height ): void {
if (
$width < self::MIN_DIMENSION_PX || $width > self::MAX_DIMENSION_PX ||
$height < self::MIN_DIMENSION_PX || $height > self::MAX_DIMENSION_PX
) {
throw new \InvalidArgumentException(
sprintf(
esc_html__( 'Image dimensions %1$dx%2$d outside supported range (%3$d–%4$d px).', 'prautoblogger' ),
$width, $height, self::MIN_DIMENSION_PX, self::MAX_DIMENSION_PX
)
);
}
}
}

View File

@@ -1,219 +0,0 @@
<?php
declare(strict_types=1);
/**
* Support helpers for the Runware image provider — API key lookup,
* response parsing (URL fetch), retry backoff, and logging.
*
* Split from the provider to keep both files under the 300-line cap.
*
* Triggered by: PRAutoBlogger_Runware_Image_Provider, PRAutoBlogger_Runware_Image_Batch.
* Dependencies: PRAutoBlogger_Encryption, PRAutoBlogger_Logger.
*
* @see class-runware-image-provider.php — Primary caller.
* @see class-runware-image-batch.php — Parallel caller.
*/
class PRAutoBlogger_Runware_Image_Support {
/**
* Runware task-based single endpoint. Unlike OpenRouter /chat/completions,
* Runware accepts a JSON array of tasks (auth + inference) at this URL.
*/
public const API_URL = 'https://api.runware.ai/v1';
/**
* Get the decrypted Runware API key from settings.
*
* @return string Plaintext key.
* @throws \RuntimeException If key is not configured or decrypt fails.
*/
public function get_api_key(): string {
$stored = (string) get_option( 'prautoblogger_runware_api_key', '' );
if ( '' === $stored ) {
throw new \RuntimeException(
__( 'Runware API key is not configured. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' )
);
}
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
$decrypted = PRAutoBlogger_Encryption::decrypt( $stored );
if ( '' === $decrypted ) {
throw new \RuntimeException(
__( 'Runware API key could not be decrypted. Re-enter in Settings.', 'prautoblogger' )
);
}
return $decrypted;
}
return $stored;
}
/**
* Extract the image URL from Runware's JSON response body.
*
* The response is `{"data": [{"taskType":"imageInference","imageURL":"..."}]}`.
* We scan `data[]` for the first imageInference entry with a non-empty URL.
*
* @param string $raw Raw JSON response body from POST /v1.
* @return string The imageURL to fetch bytes from.
* @throws \RuntimeException If the response is malformed or contains no image.
*/
public function extract_image_url( string $raw ): string {
$decoded = json_decode( $raw, true );
if ( ! is_array( $decoded ) ) {
throw new \RuntimeException(
esc_html__( 'Runware returned invalid JSON.', 'prautoblogger' )
);
}
// Runware wraps errors in an `errors` array.
if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
$first = $decoded['errors'][0] ?? [];
$msg = is_array( $first )
? (string) ( $first['message'] ?? wp_json_encode( $first ) )
: (string) $first;
throw new \RuntimeException(
sprintf(
esc_html__( 'Runware upstream error: %s', 'prautoblogger' ),
esc_html( substr( $msg, 0, 300 ) )
)
);
}
$data = $decoded['data'] ?? [];
if ( ! is_array( $data ) || empty( $data ) ) {
throw new \RuntimeException(
esc_html__( 'Runware response contained no data.', 'prautoblogger' )
);
}
foreach ( $data as $entry ) {
if ( ! is_array( $entry ) ) {
continue;
}
if ( 'imageInference' === ( $entry['taskType'] ?? '' ) ) {
$url = (string) ( $entry['imageURL'] ?? '' );
if ( '' !== $url ) {
return $url;
}
}
}
throw new \RuntimeException(
esc_html__( 'Runware response contained no imageInference imageURL.', 'prautoblogger' )
);
}
/**
* Download the image bytes from a signed Runware delivery URL.
*
* Runware returns the image URL separately and bills the inference call;
* downloading is a regular GET against `im.runware.ai`.
*
* @param string $image_url Signed image URL from extract_image_url().
* @return string Raw PNG bytes.
* @throws \RuntimeException On HTTP error or empty body.
*/
public function download_image_bytes( string $image_url ): string {
$response = wp_remote_get( $image_url, [
'timeout' => 30,
] );
if ( is_wp_error( $response ) ) {
throw new \RuntimeException(
sprintf(
esc_html__( 'Runware image download failed: %s', 'prautoblogger' ),
esc_html( $response->get_error_message() )
)
);
}
$status = (int) wp_remote_retrieve_response_code( $response );
if ( 200 !== $status ) {
throw new \RuntimeException(
sprintf(
esc_html__( 'Runware image download HTTP %d.', 'prautoblogger' ),
$status
)
);
}
$bytes = (string) wp_remote_retrieve_body( $response );
if ( '' === $bytes ) {
throw new \RuntimeException(
esc_html__( 'Runware image download returned empty body.', 'prautoblogger' )
);
}
return $bytes;
}
/**
* Snap a target pixel dimension to Runware's FLUX constraints.
*
* FLUX.1 (schnell + dev) requires dimensions divisible by 64, in the
* range [512, 2048]. We round to the nearest multiple of 64 and clamp.
*
* @param int $px Caller-requested pixel dimension.
* @return int Snapped dimension.
*/
public function snap_dimension( int $px ): int {
$snapped = (int) round( $px / 64 ) * 64;
if ( $snapped < 512 ) {
return 512;
}
if ( $snapped > 2048 ) {
return 2048;
}
return $snapped;
}
/**
* Normalize a caller seed to Runware's constraint (must be > 0).
*
* @param int|null $seed Caller-supplied seed or null for "random".
* @return int A positive integer seed.
*/
public function normalize_seed( ?int $seed ): int {
if ( null === $seed || $seed < 1 ) {
$derived = (int) ( ( microtime( true ) * 1000 ) % 2147483647 );
return $derived > 0 ? $derived : 1;
}
return $seed;
}
/**
* Emit a WARN line for a retryable image-gen failure.
*
* @param int $attempt 1-based attempt number.
* @param string $class 'network' or HTTP status code string.
* @param string $detail Short failure detail.
*/
public function log_retry( int $attempt, string $class, string $detail ): void {
PRAutoBlogger_Logger::instance()->warning(
sprintf(
'Runware image gen %s failure (attempt %d/%d): %s',
$class,
$attempt,
PRAUTOBLOGGER_MAX_RETRIES,
$detail
),
'runware-image'
);
}
/**
* Sleep between retries with exponential backoff.
*
* @param int $attempt 1-based attempt that just failed.
* @param int $retry_after Retry-After header value (0 if absent).
*/
public function backoff( int $attempt, int $retry_after = 0 ): void {
$delay = $retry_after > 0
? min( $retry_after, 60 )
: PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 );
if ( $delay > 0 ) {
sleep( $delay );
}
}
}

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
/**
* Contract for any image generation provider (Runware FLUX, OpenRouter Gemini, etc.).
* Contract for any image generation provider (Cloudflare Workers AI, DALL-E, etc.).
*
* Consumers depend on this interface, never on a concrete provider — swapping
* providers means writing one new class, not refactoring the pipeline. This
@@ -13,8 +13,7 @@ declare(strict_types=1);
* content generation run, after editorial review, before publish.
* Dependencies: None at interface level — implementations wrap a specific HTTP API.
*
* @see class-runware-image-provider.php — Runware FLUX implementation (default).
* @see class-open-router-image-provider.php — OpenRouter image implementation.
* @see class-cloudflare-image-provider.php — Default implementation (FLUX.1 via Workers AI).
* @see interface-llm-provider.php — Parallel pattern for LLM providers.
* @see ARCHITECTURE.md — External API Integrations table.
*/
@@ -80,41 +79,10 @@ interface PRAutoBlogger_Image_Provider_Interface {
/**
* Machine-readable provider identifier used in logs and settings.
*
* @return string Lowercase identifier, e.g. 'runware' or 'openrouter'.
* @return string Lowercase identifier, e.g. 'cloudflare_workers_ai'.
*/
public function get_provider_name(): string;
/**
* Generate multiple images in parallel (or sequentially as fallback).
*
* Each entry in $requests describes one image to generate. The return array
* is indexed identically — callers can zip input and output by key.
*
* Implementations that support concurrent HTTP (e.g. curl_multi) SHOULD
* override the default sequential behaviour for a wall-clock speedup.
*
* Side effects: HTTP requests (possibly concurrent), Logger lines per image.
*
* @param array<string, array{
* prompt: string,
* width: int,
* height: int,
* options?: array{seed?: int, steps?: int, model?: string},
* }> $requests Keyed image generation requests.
*
* @return array<string, array{
* bytes: string,
* mime_type: string,
* width: int,
* height: int,
* model: string,
* seed: ?int,
* cost_usd: float,
* latency_ms: int,
* }|array{error: string}> Each key maps to either image data or an error.
*/
public function generate_image_batch( array $requests ): array;
/**
* Verify the provider credentials are present and the API is reachable.
*

View File

@@ -11,7 +11,7 @@ declare(strict_types=1);
* Triggered by: PRAutoBlogger_OpenRouter_Model_Registry::refresh().
* Dependencies: None — pure data transformation, no I/O.
*
* @see services/class-open-router-model-registry.php — Sole consumer.
* @see services/class-openrouter-model-registry.php — Sole consumer.
* @see interface-model-registry.php — Defines the normalized record shape.
* @see ARCHITECTURE.md — Capability vocabulary.
*/

View File

@@ -17,7 +17,7 @@ declare(strict_types=1);
* PRAutoBlogger_OpenRouter_Model_Normalizer.
*
* @see interface-model-registry.php — Interface.
* @see class-open-router-model-normalizer.php — Normalization logic.
* @see class-openrouter-model-normalizer.php — Normalization logic.
* @see class-prautoblogger.php — Hooks the refresh action.
* @see ARCHITECTURE.md — AI Model Registries section.
*/

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
/**
* Contract for a provider-specific AI model registry.
*
* Phase 1 (v1): single implementation for OpenRouter (`class-open-router-model-registry.php`).
* Phase 3: a second implementation wraps Runware model data. A higher-level
* dispatcher iterates registered registries, calls each with the same capability,
* and unions the results — the per-registry interface stays unchanged.
* Phase 1 (v1): single implementation for OpenRouter (`class-openrouter-model-registry.php`).
* Phase 3: a second implementation wraps Cloudflare Workers AI FLUX model data. A
* higher-level dispatcher iterates registered registries, calls each with the same
* capability, and unions the results — the per-registry interface stays unchanged.
*
* Implementations MUST:
* - Store the normalized registry as a WP option + transient cache layer.
@@ -19,7 +19,7 @@ declare(strict_types=1);
* (commit 3), daily cron via the main plugin loader.
* Dependencies: None at interface level — implementations wrap an HTTP API.
*
* @see services/class-open-router-model-registry.php — Default implementation.
* @see services/class-openrouter-model-registry.php — Default implementation.
* @see admin/fields/class-openrouter-model-field.php — Field renderer that queries this interface.
* @see ARCHITECTURE.md — AI Model Registries section.
*/
@@ -86,7 +86,7 @@ interface PRAutoBlogger_Model_Registry_Interface {
public function get_fetched_at(): int;
/**
* Machine-readable provider identifier (e.g. 'openrouter', 'runware').
* Machine-readable provider identifier (e.g. 'openrouter', 'cloudflare_workers_ai').
*
* @return string
*/

View File

@@ -8,7 +8,7 @@ declare(strict_types=1);
* Plugin Name: PRAutoBlogger
* Plugin URI: https://peptiderepo.com/prautoblogger
* 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.10.0
* Version: 0.2.3
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: PeptideRepo
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
| and limits without magic strings.
*/
define( 'PRAUTOBLOGGER_VERSION', '0.10.0' );
define( 'PRAUTOBLOGGER_VERSION', '0.2.3' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
@@ -45,7 +45,7 @@ define( 'PRAUTOBLOGGER_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
// API and retry limits — named constants instead of magic numbers.
define( 'PRAUTOBLOGGER_MAX_RETRIES', 3 );
define( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS', 2 );
define( 'PRAUTOBLOGGER_API_TIMEOUT_SECONDS', 180 );
define( 'PRAUTOBLOGGER_API_TIMEOUT_SECONDS', 120 );
define( 'PRAUTOBLOGGER_CACHE_TTL_SECONDS', 3600 );
// Default models for OpenRouter (user can override in settings).
@@ -53,22 +53,17 @@ define( 'PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL', 'google/gemini-2.5-flash-lite' )
define( 'PRAUTOBLOGGER_DEFAULT_WRITING_MODEL', 'google/gemini-2.5-flash-lite' );
define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' );
// Image generation defaults.
// Default provider is Runware; OpenRouter (Gemini) remains as the alternate.
// Default model is FLUX.1 schnell via Runware (~$0.0006/image, ~2s). CEO
// decision 2026-04-21 after comic A/B: schnell's looser fidelity reads as
// editorial-cartoon style and is 65× cheaper than Nano Banana. Users can
// override via Settings → Images → Image Model.
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER', 'runware' );
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'runware:100@1' );
// Image generation defaults (Cloudflare Workers AI, FLUX family).
// Short alias; the Cloudflare image pricing helper expands it to the full
// `@cf/black-forest-labs/...` model id when building the Workers AI URL.
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'flux-1-schnell' );
// Default style suffix appended to every image-gen prompt. Updated
// 2026-04-18 v4: single-panel newspaper comic style. Caption is now
// inserted as HTML text below the image, NOT rendered inside it.
// Users may override via Settings → Images → Style Suffix.
// CEO-approved image style suffix (decision locked 2026-04-15). Users may
// override via Settings → Images → Style Suffix; this constant is the
// fallback the setting reads on first install.
define(
'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX',
'Style: a single-panel newspaper comic in classic black ink on white paper, simple clean linework like The Far Side or B.C., slightly yellowed newsprint texture, hand-drawn crosshatching for shading, bold panel border, warm and humorous tone, visible in a newspaper comics section. IMPORTANT: Do not render any text, captions, speech bubbles, or words inside the image.'
'Style: a screengrab from a 1995 late-night cable TV infomercial, oversaturated warm colors, VHS tape noise and scan lines, a bright yellow starburst graphic in the top right corner reading NEW, cheap studio lighting, dated 4:3 television framing, deadpan earnest expression, faint lens distortion, tacky low-budget commercial aesthetic.'
);
/*

View File

@@ -1,179 +0,0 @@
<?php
/**
* Ideas browser template — lists analysis results (article ideas) from the pipeline.
*
* Variables available from PRAutoBlogger_Ideas_Browser::render_page():
* $rows (array[]) — analysis result rows from the database.
* $total (int) — total row count (before pagination).
* $total_pages (int) — total number of pages.
* $type_filter (string) — active type filter (empty = all).
* $type_counts (array) — map of analysis_type => count for filter badges.
* $paged (int) — current page number.
*
* @see admin/class-ideas-browser.php — Renders this template.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$base_url = admin_url( 'admin.php?page=prautoblogger-ideas' );
?>
<style>
.ab-spin { animation: ab-spin 1s linear infinite; display: inline-block; }
@keyframes ab-spin { 100% { transform: rotate(360deg); } }
</style>
<div class="wrap prautoblogger-ideas-browser">
<h1><?php esc_html_e( 'PRAutoBlogger — Ideas', 'prautoblogger' ); ?></h1>
<p class="description">
<?php
printf(
/* translators: %d: total idea count */
esc_html__( '%d ideas collected from analysis runs. Newest first.', 'prautoblogger' ),
$total
);
?>
</p>
<?php if ( ! empty( $type_counts ) ) : ?>
<div class="prautoblogger-ideas-filters" style="margin: 12px 0;">
<a href="<?php echo esc_url( $base_url ); ?>"
class="button <?php echo '' === $type_filter ? 'button-primary' : ''; ?>"
style="margin-right: 4px;">
<?php printf( '%s (%d)', esc_html__( 'All', 'prautoblogger' ), $total ); ?>
</a>
<?php foreach ( $type_counts as $type_name => $count ) : ?>
<a href="<?php echo esc_url( add_query_arg( 'type', $type_name, $base_url ) ); ?>"
class="button <?php echo $type_filter === $type_name ? 'button-primary' : ''; ?>"
style="margin-right: 4px;">
<?php echo esc_html( ucfirst( $type_name ) ) . ' (' . (int) $count . ')'; ?>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ( empty( $rows ) ) : ?>
<div class="prautoblogger-empty-queue">
<p><?php esc_html_e( 'No ideas found. Ideas will appear here after the next generation run.', 'prautoblogger' ); ?></p>
</div>
<?php else : ?>
<table class="widefat striped prautoblogger-ideas-table">
<thead>
<tr>
<th style="width:28%"><?php esc_html_e( 'Title / Topic', 'prautoblogger' ); ?></th>
<th style="width:7%"><?php esc_html_e( 'Type', 'prautoblogger' ); ?></th>
<th style="width:6%"><?php esc_html_e( 'Score', 'prautoblogger' ); ?></th>
<th style="width:5%"><?php esc_html_e( 'Freq', 'prautoblogger' ); ?></th>
<th style="width:30%"><?php esc_html_e( 'Key Points / Keywords', 'prautoblogger' ); ?></th>
<th style="width:10%"><?php esc_html_e( 'Analyzed', 'prautoblogger' ); ?></th>
<th style="width:14%"><?php esc_html_e( 'Generate', 'prautoblogger' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $rows as $row ) : ?>
<?php
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?: [];
$suggested = $meta['suggested_title'] ?? '';
$key_points = $meta['key_points'] ?? [];
$keywords = $meta['target_keywords'] ?? [];
$score_pct = round( (float) $row['relevance_score'] * 100 );
$type_label = ucfirst( $row['analysis_type'] ?? 'unknown' );
$type_class = 'prab-type-' . sanitize_html_class( $row['analysis_type'] );
$idea_id = (int) $row['id'];
// Check if this idea is already mid-generation.
$idea_status = get_transient( 'prab_idea_gen_' . $idea_id );
$gen_state = is_array( $idea_status ) ? ( $idea_status['status'] ?? 'idle' ) : 'idle';
?>
<tr>
<td>
<strong><?php echo esc_html( '' !== $suggested ? $suggested : $row['topic'] ); ?></strong>
<?php if ( '' !== $suggested && $suggested !== $row['topic'] ) : ?>
<div style="color:#666; font-size:12px; margin-top:2px;">
<?php echo esc_html( $row['topic'] ); ?>
</div>
<?php endif; ?>
<?php if ( ! empty( $row['summary'] ) ) : ?>
<div style="color:#555; font-size:12px; margin-top:4px; line-height:1.4;">
<?php echo esc_html( $row['summary'] ); ?>
</div>
<?php endif; ?>
</td>
<td>
<span class="<?php echo esc_attr( $type_class ); ?>" style="padding:2px 8px; border-radius:3px; font-size:12px; background:#f0f0f1;">
<?php echo esc_html( $type_label ); ?>
</span>
</td>
<td>
<?php $bar_color = $score_pct >= 70 ? '#00a32a' : ( $score_pct >= 40 ? '#dba617' : '#d63638' ); ?>
<div style="display:flex; align-items:center; gap:6px;">
<div style="width:40px; height:6px; background:#ddd; border-radius:3px; overflow:hidden;">
<div style="width:<?php echo (int) $score_pct; ?>%; height:100%; background:<?php echo esc_attr( $bar_color ); ?>;"></div>
</div>
<span style="font-size:12px;"><?php echo (int) $score_pct; ?>%</span>
</div>
</td>
<td style="text-align:center;"><?php echo (int) $row['frequency']; ?></td>
<td>
<?php if ( ! empty( $key_points ) ) : ?>
<ul style="margin:0; padding-left:16px; font-size:12px; line-height:1.5;">
<?php foreach ( array_slice( $key_points, 0, 4 ) as $point ) : ?>
<li><?php echo esc_html( $point ); ?></li>
<?php endforeach; ?>
<?php if ( count( $key_points ) > 4 ) : ?>
<li style="color:#666;">+<?php echo count( $key_points ) - 4; ?> more</li>
<?php endif; ?>
</ul>
<?php endif; ?>
<?php if ( ! empty( $keywords ) ) : ?>
<div style="margin-top:4px;">
<?php foreach ( array_slice( $keywords, 0, 5 ) as $kw ) : ?>
<span style="display:inline-block; padding:1px 6px; margin:1px 2px; background:#e7f5fe; border-radius:3px; font-size:11px; color:#0073aa;">
<?php echo esc_html( $kw ); ?>
</span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</td>
<td style="font-size:12px; color:#666;">
<?php echo esc_html( wp_date( 'M j, g:i A', strtotime( $row['analyzed_at'] ) ) ); ?>
</td>
<td class="prab-idea-gen-cell"
data-idea-id="<?php echo $idea_id; ?>"
data-status="<?php echo esc_attr( $gen_state ); ?>">
<?php if ( 'complete' === $gen_state && ! empty( $idea_status['post_id'] ) ) : ?>
<span class="dashicons dashicons-yes-alt" style="color:#00a32a;"></span>
<a href="<?php echo esc_url( admin_url( 'post.php?post=' . $idea_status['post_id'] . '&action=edit' ) ); ?>">Edit</a>
| <a href="<?php echo esc_url( get_permalink( $idea_status['post_id'] ) ); ?>" target="_blank">View</a>
<?php elseif ( 'running' === $gen_state ) : ?>
<span class="dashicons dashicons-update ab-spin"></span>
<span class="prab-idea-stage"><?php echo esc_html( $idea_status['stage'] ?? 'Generating…' ); ?></span>
<?php else : ?>
<button type="button"
class="button button-small prab-gen-idea-btn"
data-idea-id="<?php echo $idea_id; ?>">
<?php esc_html_e( 'Generate', 'prautoblogger' ); ?>
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if ( $total_pages > 1 ) : ?>
<div class="prautoblogger-pagination" style="margin-top: 12px;">
<?php
echo wp_kses_post( paginate_links( [
'base' => add_query_arg( 'paged', '%#%' ),
'format' => '',
'current' => $paged,
'total' => $total_pages,
'prev_text' => '&laquo;',
'next_text' => '&raquo;',
] ) );
?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>

View File

@@ -9,15 +9,15 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$cost_reporter = new PRAutoBlogger_Cost_Reporter();
$monthly_spend = $cost_reporter->get_monthly_spend();
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
$monthly_spend = $cost_tracker->get_monthly_spend();
$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
$utilization = $cost_reporter->get_budget_utilization();
$daily_spend = $cost_reporter->get_daily_spend( 30 );
$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_reporter->get_spend_by_stage( $first_of_month, $today );
$stage_breakdown = $cost_tracker->get_spend_by_stage( $first_of_month, $today );
?>
<div class="wrap prautoblogger-metrics">
<h1><?php esc_html_e( 'PRAutoBlogger — Metrics & Costs', 'prautoblogger' ); ?></h1>

View File

@@ -14,8 +14,8 @@ if ( ! defined( 'ABSPATH' ) ) {
}
$active_tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'prautoblogger_api';
$cost_reporter = new PRAutoBlogger_Cost_Reporter();
$monthly_spend = $cost_reporter->get_monthly_spend();
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
$monthly_spend = $cost_tracker->get_monthly_spend();
$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
$utilization = $budget > 0 ? ( $monthly_spend / $budget ) * 100.0 : 0;
$next_run = wp_next_scheduled( 'prautoblogger_daily_generation' );

View File

@@ -55,20 +55,6 @@ if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL' ) ) {
define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' );
}
// Default image model slug used by OpenRouter/Runware pricing fallbacks and
// settings UI. Source of truth: prautoblogger.php line 63.
// Historically this was defined implicitly by CloudflareImageProviderTest::setUp()
// — that test was deleted in v0.10.0 (cloudflare-workers-ai-removal), so the
// define now lives here where it belongs.
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) {
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'runware:100@1' );
}
// Default image style suffix used by ImagePromptBuilder and settings UI.
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX' ) ) {
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX', 'Style: test infomercial style.' );
}
// WordPress database constants used in $wpdb queries.
if ( ! defined( 'ARRAY_A' ) ) {
define( 'ARRAY_A', 'ARRAY_A' );
@@ -106,39 +92,3 @@ if ( ! class_exists( 'WP_Query' ) ) {
}
}
}
// NOTE: Do NOT stub wp_strip_all_tags() or sanitize_text_field() here.
// Brain Monkey / Patchwork manages these stubs in individual tests via
// Functions\when(). Defining them in bootstrap causes Patchwork's
// "DefinedTooEarly" error.
// WP_Error stub — used by model registry and image pipeline tests.
// This is safe to define here because it's a class (not a function),
// so Patchwork doesn't need to intercept its definition.
if ( ! class_exists( 'WP_Error' ) ) {
class WP_Error {
public $errors = [];
public $error_data = [];
public function __construct( $code = '', $message = '', $data = '' ) {
if ( $code ) {
$this->errors[ $code ][] = $message;
if ( $data ) {
$this->error_data[ $code ] = $data;
}
}
}
public function get_error_code() {
$codes = array_keys( $this->errors );
return $codes ? $codes[0] : '';
}
public function get_error_message( $code = '' ) {
if ( ! $code ) {
$code = $this->get_error_code();
}
return isset( $this->errors[ $code ] ) ? $this->errors[ $code ][0] : '';
}
}
}

View File

@@ -1,133 +0,0 @@
<?php
/**
* Tests for the v0.8.2 one-shot migration that reschedules the daily
* generation cron in the site's configured timezone. Exercises both
* the "first run" path and the "already migrated, no-op" path.
*
* See thread scheduled-gen-timezone/01-cto-handoff.md.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class ActivatorMigrationTest extends BaseTestCase {
/**
* Track cron scheduling side effects so we can assert on them.
*
* @var array<string, array{scheduled_at: int|null, recurrence: string|null, cleared: bool}>
*/
private array $cron_state = [];
/**
* Track the v0.8.2 migration flag across get_option/update_option calls.
*
* @var array<string, mixed>
*/
private array $options = [];
protected function setUp(): void {
parent::setUp();
$this->cron_state = [
'prautoblogger_daily_generation' => [
'scheduled_at' => null,
'recurrence' => null,
'cleared' => false,
],
];
// Start with a pre-existing cron simulating the v<0.8.2 UTC-interpreted
// schedule, so the migration has something to clear.
$this->cron_state['prautoblogger_daily_generation']['scheduled_at'] = strtotime( 'tomorrow 03:00 UTC' );
$this->cron_state['prautoblogger_daily_generation']['recurrence'] = 'daily';
// Baseline options.
$this->options = [
'prautoblogger_schedule_time' => '06:00',
'prautoblogger_log_level' => 'info',
];
Functions\when( 'get_option' )->alias(
fn( $key, $default = false ) => $this->options[ $key ] ?? $default
);
Functions\when( 'update_option' )->alias(
function ( $key, $value ) {
$this->options[ $key ] = $value;
return true;
}
);
Functions\when( 'wp_next_scheduled' )->alias(
fn( $hook ) => $this->cron_state[ $hook ]['scheduled_at'] ?? false
);
Functions\when( 'wp_clear_scheduled_hook' )->alias(
function ( $hook ) {
if ( isset( $this->cron_state[ $hook ] ) ) {
$this->cron_state[ $hook ]['scheduled_at'] = null;
$this->cron_state[ $hook ]['cleared'] = true;
}
}
);
Functions\when( 'wp_schedule_event' )->alias(
function ( $timestamp, $recurrence, $hook ) {
$this->cron_state[ $hook ]['scheduled_at'] = (int) $timestamp;
$this->cron_state[ $hook ]['recurrence'] = (string) $recurrence;
return true;
}
);
Functions\when( 'wp_timezone' )->alias(
static fn() => new \DateTimeZone( 'Asia/Singapore' )
);
}
/**
* Migration clears the UTC-scheduled event and reschedules in SGT.
* Flag is set so the next pass is a no-op.
*/
public function test_db_version_migration_reschedules_existing_cron(): void {
$before_timestamp = $this->cron_state['prautoblogger_daily_generation']['scheduled_at'];
$this->assertNotNull( $before_timestamp, 'Precondition: stale cron was pre-seeded.' );
\PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
// Hook was cleared and then re-scheduled in one pass.
$this->assertTrue( $this->cron_state['prautoblogger_daily_generation']['cleared'] );
$after = $this->cron_state['prautoblogger_daily_generation']['scheduled_at'];
$this->assertNotNull( $after );
$this->assertNotSame( $before_timestamp, $after, 'Migration should produce a different timestamp.' );
// Re-scheduled timestamp maps to 06:00 SGT (22:00 UTC prev day).
$utc = ( new \DateTimeImmutable( '@' . $after ) )->setTimezone( new \DateTimeZone( 'UTC' ) );
$this->assertSame( '22:00', $utc->format( 'H:i' ) );
// Flag was set.
$this->assertSame( '1', $this->options['prautoblogger_migrated_schedule_tz_v082'] ?? null );
}
/**
* Running the migration twice is idempotent — the second pass must
* return immediately without clearing or rescheduling.
*/
public function test_db_version_migration_idempotent(): void {
\PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
// Reset the cleared marker so we can detect a second clear.
$this->cron_state['prautoblogger_daily_generation']['cleared'] = false;
$timestamp_after_first = $this->cron_state['prautoblogger_daily_generation']['scheduled_at'];
\PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
// No second clear, no re-schedule.
$this->assertFalse( $this->cron_state['prautoblogger_daily_generation']['cleared'] );
$this->assertSame(
$timestamp_after_first,
$this->cron_state['prautoblogger_daily_generation']['scheduled_at']
);
}
}

View File

@@ -1,163 +0,0 @@
<?php
/**
* Tests for the v0.10.0 one-shot migration that removes Cloudflare
* Workers AI as an image provider — exercises the cloudflare-user
* path, the non-cloudflare-user path, idempotency, and the critical
* scope-boundary invariant that `prautoblogger_ai_gateway_base_url`
* (shared with the OpenRouter AI Gateway route) is left intact.
*
* See thread 2026-04-cloudflare-workers-ai-removal/01-cto-handoff.md.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class ActivatorRemoveCloudflareTest extends BaseTestCase {
/** @var array<string, mixed> */
private array $options = [];
protected function setUp(): void {
parent::setUp();
$this->options = [];
Functions\when( 'get_option' )->alias(
fn( $key, $default = false ) => $this->options[ $key ] ?? $default
);
Functions\when( 'update_option' )->alias(
function ( $key, $value ) {
$this->options[ $key ] = $value;
return true;
}
);
Functions\when( 'delete_option' )->alias(
function ( $key ) {
unset( $this->options[ $key ] );
return true;
}
);
}
/**
* Cloudflare user: flipped to Runware schnell, CF credentials removed,
* gateway URL preserved, flag set.
*/
public function test_cloudflare_user_migrated_to_runware(): void {
$this->options = [
'prautoblogger_image_provider' => 'cloudflare',
'prautoblogger_image_model' => 'flux-1-schnell',
'prautoblogger_cloudflare_ai_token' => 'enc:SECRET',
'prautoblogger_cloudflare_account_id' => 'acct-123',
'prautoblogger_cf_image_via_gateway' => '1',
// Shared with OpenRouter AI Gateway — MUST be preserved.
'prautoblogger_ai_gateway_base_url' => 'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter',
];
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
$this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] );
$this->assertSame( 'runware:100@1', $this->options['prautoblogger_image_model'] );
$this->assertArrayNotHasKey( 'prautoblogger_cloudflare_ai_token', $this->options );
$this->assertArrayNotHasKey( 'prautoblogger_cloudflare_account_id', $this->options );
$this->assertArrayNotHasKey( 'prautoblogger_cf_image_via_gateway', $this->options );
$this->assertSame(
'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter',
$this->options['prautoblogger_ai_gateway_base_url'],
'AI Gateway URL (shared with OpenRouter route) must NOT be touched.'
);
$this->assertSame( '1', $this->options['prautoblogger_migrated_remove_cloudflare_v0100'] );
}
/**
* Non-Cloudflare user (e.g. already on OpenRouter Gemini): model +
* provider untouched; CF credentials still swept; flag set.
*/
public function test_non_cloudflare_user_preserved(): void {
$this->options = [
'prautoblogger_image_provider' => 'openrouter',
'prautoblogger_image_model' => 'google/gemini-2.5-flash-image',
// Hypothetical orphans from an earlier experiment.
'prautoblogger_cloudflare_ai_token' => 'enc:OLD',
'prautoblogger_cloudflare_account_id' => 'acct-999',
'prautoblogger_ai_gateway_base_url' => 'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter',
];
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
$this->assertSame( 'openrouter', $this->options['prautoblogger_image_provider'] );
$this->assertSame( 'google/gemini-2.5-flash-image', $this->options['prautoblogger_image_model'] );
$this->assertArrayNotHasKey( 'prautoblogger_cloudflare_ai_token', $this->options );
$this->assertArrayNotHasKey( 'prautoblogger_cloudflare_account_id', $this->options );
$this->assertSame(
'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter',
$this->options['prautoblogger_ai_gateway_base_url']
);
$this->assertSame( '1', $this->options['prautoblogger_migrated_remove_cloudflare_v0100'] );
}
/**
* Runware user: provider + model untouched; flag set.
*/
public function test_runware_user_preserved(): void {
$this->options = [
'prautoblogger_image_provider' => 'runware',
'prautoblogger_image_model' => 'runware:101@1',
];
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
$this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] );
$this->assertSame( 'runware:101@1', $this->options['prautoblogger_image_model'] );
$this->assertSame( '1', $this->options['prautoblogger_migrated_remove_cloudflare_v0100'] );
}
/**
* Legacy slug detection — the pre-registry `flux-1-schnell` model id
* still gets migrated even when the provider option is empty.
*/
public function test_legacy_slug_triggers_migration(): void {
$this->options = [
'prautoblogger_image_provider' => '', // Unset before v0.9.0 landed.
'prautoblogger_image_model' => 'flux-1-schnell',
];
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
$this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] );
$this->assertSame( 'runware:100@1', $this->options['prautoblogger_image_model'] );
}
/**
* Running the migration twice is idempotent — the second pass must
* return immediately and must not clobber a user change made after
* the first migration run.
*/
public function test_idempotent_second_run_is_noop(): void {
$this->options = [
'prautoblogger_image_provider' => 'cloudflare',
'prautoblogger_image_model' => 'flux-1-schnell',
];
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
$this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] );
// Simulate admin manually switching to OpenRouter after migration.
$this->options['prautoblogger_image_provider'] = 'openrouter';
$this->options['prautoblogger_image_model'] = 'google/gemini-3.1-flash-image-preview';
\PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
// Second run must NOT overwrite the admin's manual choice.
$this->assertSame( 'openrouter', $this->options['prautoblogger_image_provider'] );
$this->assertSame( 'google/gemini-3.1-flash-image-preview', $this->options['prautoblogger_image_model'] );
}
}

View File

@@ -1,91 +0,0 @@
<?php
/**
* Tests for `PRAutoBlogger_Activator::next_daily_generation_timestamp()` —
* the v0.8.2 timezone-aware replacement for `strtotime("tomorrow HH:MM")`
* which evaluated in UTC regardless of the WordPress site timezone.
*
* See thread scheduled-gen-timezone/01-cto-handoff.md for the bug report.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class ActivatorScheduleTest extends BaseTestCase {
/**
* Stub `wp_timezone()` to return the given tz id, and seed
* `prautoblogger_schedule_time` with the given value.
*/
private function with_site_tz_and_time( string $tz_id, string $time_str ): void {
$this->stub_get_option( [
'prautoblogger_schedule_time' => $time_str,
] );
Functions\when( 'wp_timezone' )->alias(
static fn() => new \DateTimeZone( $tz_id )
);
}
/**
* On an Asia/Singapore site (UTC+8), `schedule_time = 06:00` must
* resolve to 22:00 UTC the previous day — the prod bug from thread
* scheduled-gen-timezone seq 1.
*/
public function test_schedule_cron_uses_site_timezone(): void {
$this->with_site_tz_and_time( 'Asia/Singapore', '06:00' );
$timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp();
$this->assertGreaterThan( 0, $timestamp );
$utc = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'UTC' ) );
$this->assertSame( '22:00', $utc->format( 'H:i' ), 'Expected 06:00 SGT == 22:00 UTC previous day' );
// And it really is "tomorrow" in Singapore — date part should not be today SGT.
$sgt = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'Asia/Singapore' ) );
$tomorrow_sgt = ( new \DateTimeImmutable( 'tomorrow', new \DateTimeZone( 'Asia/Singapore' ) ) )->format( 'Y-m-d' );
$this->assertSame( $tomorrow_sgt, $sgt->format( 'Y-m-d' ) );
}
/**
* UTC site, `06:00` input → 06:00 UTC. Behaviour unchanged for
* the always-UTC case; regression guard.
*/
public function test_schedule_cron_handles_utc_site(): void {
$this->with_site_tz_and_time( 'UTC', '06:00' );
$timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp();
$utc = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'UTC' ) );
$this->assertSame( '06:00', $utc->format( 'H:i' ) );
}
/**
* Nepal runs UTC+5:45. 06:00 local → 00:15 UTC. Guards against any
* integer-hour-offset assumptions in the conversion logic.
*/
public function test_schedule_cron_handles_fractional_offset(): void {
$this->with_site_tz_and_time( 'Asia/Kathmandu', '06:00' );
$timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp();
$utc = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( new \DateTimeZone( 'UTC' ) );
$this->assertSame( '00:15', $utc->format( 'H:i' ) );
}
/**
* Malformed `schedule_time` (e.g. blank string saved accidentally)
* must not throw — `absint()` clamps to 0:0 and the helper returns
* a valid timestamp. Regression guard against the "bricked schedule
* on bad input" failure mode.
*/
public function test_schedule_cron_survives_malformed_time_input(): void {
$this->with_site_tz_and_time( 'UTC', '' );
$timestamp = \PRAutoBlogger_Activator::next_daily_generation_timestamp();
$this->assertGreaterThan( 0, $timestamp );
}
}

View File

@@ -1,93 +0,0 @@
<?php
/**
* Tests for PRAutoBlogger_Cost_Reporter.
*
* Validates monthly spend, daily spend trends, spend-by-stage breakdowns,
* and budget utilization reporting methods.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class CostReporterTest extends BaseTestCase {
/**
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb instance.
*/
private $wpdb;
protected function setUp(): void {
parent::setUp();
$this->wpdb = $this->create_mock_wpdb();
$GLOBALS['wpdb'] = $this->wpdb;
// CostReporter methods call get_option for budget settings.
$this->stub_get_option( [
'prautoblogger_monthly_budget_usd' => '50.00',
] );
// Stub current_time used in date-based queries.
$this->stub_current_time( '2026-04-12 10:00:00' );
}
protected function tearDown(): void {
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/**
* Test get_monthly_spend returns float.
*/
public function test_get_monthly_spend_returns_float(): void {
$this->wpdb->method( 'prepare' )->willReturn( 'prepared' );
$this->wpdb->method( 'get_var' )->willReturn( '10.50' );
$reporter = new \PRAutoBlogger_Cost_Reporter();
$spend = $reporter->get_monthly_spend();
$this->assertIsFloat( $spend );
$this->assertGreaterThanOrEqual( 0.0, $spend );
}
/**
* Test get_daily_spend returns array.
*/
public function test_get_daily_spend_returns_array(): void {
$this->wpdb->method( 'prepare' )->willReturn( 'prepared' );
$this->wpdb->method( 'get_results' )->willReturn( [] );
$reporter = new \PRAutoBlogger_Cost_Reporter();
$daily = $reporter->get_daily_spend( 30 );
$this->assertIsArray( $daily );
}
/**
* Test get_spend_by_stage returns array.
*/
public function test_get_spend_by_stage_returns_array(): void {
$this->wpdb->method( 'prepare' )->willReturn( 'prepared' );
$this->wpdb->method( 'get_results' )->willReturn( [] );
$reporter = new \PRAutoBlogger_Cost_Reporter();
$spend = $reporter->get_spend_by_stage( '2026-04-01', '2026-04-30' );
$this->assertIsArray( $spend );
}
/**
* Test get_budget_utilization returns float between 0 and 100+.
*/
public function test_get_budget_utilization_returns_float(): void {
$reporter = new \PRAutoBlogger_Cost_Reporter();
$utilization = $reporter->get_budget_utilization();
$this->assertIsFloat( $utilization );
$this->assertGreaterThanOrEqual( 0.0, $utilization );
}
}

View File

@@ -68,6 +68,20 @@ class CostTrackerTest extends BaseTestCase {
$this->assertFalse( $tracker->is_budget_exceeded() );
}
/**
* Test get_monthly_spend returns float.
*/
public function test_get_monthly_spend_returns_float(): void {
$this->wpdb->method( 'prepare' )->willReturn( 'prepared' );
$this->wpdb->method( 'get_var' )->willReturn( '10.50' );
$tracker = new \PRAutoBlogger_Cost_Tracker();
$spend = $tracker->get_monthly_spend();
$this->assertIsFloat( $spend );
$this->assertGreaterThanOrEqual( 0.0, $spend );
}
/**
* Test get_current_run_cost returns float.
*/
@@ -84,6 +98,43 @@ class CostTrackerTest extends BaseTestCase {
$this->assertGreaterThanOrEqual( 0.0, $cost );
}
/**
* Test get_daily_spend returns array.
*/
public function test_get_daily_spend_returns_array(): void {
$this->wpdb->method( 'prepare' )->willReturn( 'prepared' );
$this->wpdb->method( 'get_results' )->willReturn( [] );
$tracker = new \PRAutoBlogger_Cost_Tracker();
$daily = $tracker->get_daily_spend( 30 );
$this->assertIsArray( $daily );
}
/**
* Test get_spend_by_stage returns array.
*/
public function test_get_spend_by_stage_returns_array(): void {
$this->wpdb->method( 'prepare' )->willReturn( 'prepared' );
$this->wpdb->method( 'get_results' )->willReturn( [] );
$tracker = new \PRAutoBlogger_Cost_Tracker();
$spend = $tracker->get_spend_by_stage( '2026-04-01', '2026-04-30' );
$this->assertIsArray( $spend );
}
/**
* Test get_budget_utilization returns float between 0 and 1.
*/
public function test_get_budget_utilization_returns_float(): void {
$tracker = new \PRAutoBlogger_Cost_Tracker();
$utilization = $tracker->get_budget_utilization();
$this->assertIsFloat( $utilization );
$this->assertGreaterThanOrEqual( 0.0, $utilization );
}
/**
* Test log_api_call with mock database.
*/

View File

@@ -21,9 +21,6 @@ class IdeaScorerTest extends BaseTestCase {
$this->stub_get_option( [
'prautoblogger_topic_exclusions' => '[]',
] );
// has_similar_post() → fetch_existing_titles() calls get_the_title().
Functions\when( 'get_the_title' )->justReturn( 'Existing Post Title' );
}
/**
@@ -144,27 +141,4 @@ class IdeaScorerTest extends BaseTestCase {
$this->assertIsArray( $result );
}
/**
* Test that topics sharing only common niche words are NOT deduped.
*
* Regression test for the WP_Query 's' bug: WordPress search matched
* ANY word, so "BPC-157 dosage" deduped against "BPC-157 storage"
* even though they're completely different articles.
*/
public function test_dedup_does_not_reject_different_topics_with_shared_words(): void {
// The default WP_Query stub returns 0 posts, so nothing gets deduped.
// This test verifies that score_and_rank passes ideas through when
// no similar posts exist (the common case after the fix).
$fixture = $this->get_analysis_result_fixture();
$fixture['topic'] = 'BPC-157 dosage guide for subcutaneous injection';
$scorer = new \PRAutoBlogger_Idea_Scorer();
$result = $scorer->score_and_rank(
[ new \PRAutoBlogger_Analysis_Result( $fixture ) ],
5
);
$this->assertCount( 1, $result, 'Idea should not be deduped when no similar post exists.' );
}
}

View File

@@ -18,11 +18,8 @@ class ImagePipelineTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
// Mock WordPress functions used by the pipeline and its dependencies.
Functions\when( 'is_wp_error' )->alias( function ( $thing ) {
return $thing instanceof \WP_Error;
} );
Functions\when( 'wp_generate_uuid4' )->justReturn( 'test-uuid' );
// Mock WordPress functions.
Functions\when( 'wp_generate_uuid4' )->returnArg();
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = [
'prautoblogger_image_enabled' => '1',
@@ -31,20 +28,7 @@ class ImagePipelineTest extends BaseTestCase {
return $options[ $key ] ?? $default;
} );
Functions\when( 'sanitize_text_field' )->returnArg();
Functions\when( 'wp_strip_all_tags' )->alias( function ( $str ) {
return trim( strip_tags( $str ) );
} );
Functions\when( 'update_post_meta' )->justReturn( true );
Functions\when( 'set_post_thumbnail' )->justReturn( true );
Functions\when( 'get_post_meta' )->justReturn( '' );
Functions\when( 'get_post' )->justReturn( (object) [ 'ID' => 1, 'post_content' => '<p>Test content</p>' ] );
Functions\when( 'wp_update_post' )->justReturn( 1 );
Functions\when( 'wp_get_attachment_url' )->justReturn( 'https://example.com/img.png' );
Functions\when( 'sanitize_title' )->returnArg();
Functions\when( 'esc_html__' )->returnArg();
Functions\when( 'esc_url' )->returnArg();
Functions\when( 'esc_attr' )->returnArg();
Functions\when( 'esc_html' )->returnArg();
}
/**
@@ -56,10 +40,13 @@ class ImagePipelineTest extends BaseTestCase {
return $options[ $key ] ?? $default;
} );
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->expects( $this->never() )->method( 'generate_image' );
// Mock the logger.
$logger = $this->createMock( \PRAutoBlogger_Logger::class );
$logger->expects( $this->once() )->method( 'info' );
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->expects( $this->never() )->method( 'generate_image' );
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ] );
@@ -71,32 +58,28 @@ class ImagePipelineTest extends BaseTestCase {
/**
* Test generate_and_attach_images() returns cost and error array structure.
*
* Pipeline now calls generate_image_batch() with keyed requests.
*/
public function test_generate_and_attach_images_returns_correct_structure(): void {
$image_result = [
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
$provider->method( 'generate_image' )->willReturn( [
'bytes' => 'fake_image_data',
'mime_type' => 'image/png',
'width' => 1200,
'height' => 630,
'model' => 'test-model',
'model' => 'flux-1-schnell',
'seed' => 123,
'cost_usd' => 0.05,
'latency_ms' => 2000,
];
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
$provider->method( 'generate_image_batch' )->willReturn( [
'image_a' => $image_result,
] );
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
$cost_tracker->method( 'log_image_generation' )->willReturn( true );
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
// Mock file writing for sideload.
Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' );
Functions\when( 'media_handle_sideload' )->justReturn( 42 );
@@ -109,24 +92,19 @@ class ImagePipelineTest extends BaseTestCase {
}
/**
* Test that cost is accumulated when both images are generated via batch.
* Test that cost is accumulated when both images are generated.
*/
public function test_cost_is_accumulated_for_both_images(): void {
$image_result = [
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
$provider->method( 'generate_image' )->willReturn( [
'bytes' => 'fake_image_data',
'mime_type' => 'image/png',
'width' => 1200,
'height' => 630,
'model' => 'test-model',
'model' => 'flux-1-schnell',
'cost_usd' => 0.05,
'latency_ms' => 2000,
];
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
$provider->method( 'generate_image_batch' )->willReturn( [
'image_a' => $image_result,
'image_b' => $image_result,
] );
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
@@ -144,60 +122,17 @@ class ImagePipelineTest extends BaseTestCase {
[ 'title' => 'Source' ]
);
// Cost should be accumulated from both images ($0.05 × 2 = $0.10).
$this->assertEqualsWithDelta( 0.10, $result['cost_usd'], 0.001 );
// Cost should be accumulated from both images.
$this->assertGreaterThan( 0.05, $result['cost_usd'] );
}
/**
* Test that Image B is NOT included in the batch when source_data is null.
*
* Regression test: the pipeline must only send 'image_a' in the batch
* request when no source data is provided.
* Test graceful handling when image generation fails.
*/
public function test_image_b_skipped_when_source_data_is_null(): void {
$image_result = [
'bytes' => 'fake_image_data',
'mime_type' => 'image/png',
'width' => 1200,
'height' => 630,
'model' => 'test-model',
'cost_usd' => 0.05,
'latency_ms' => 2000,
];
public function test_graceful_failure_when_image_generation_fails(): void {
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
// Verify batch only contains 'image_a'.
$provider->expects( $this->once() )
->method( 'generate_image_batch' )
->with( $this->callback( function ( $requests ) {
return array_key_exists( 'image_a', $requests )
&& ! array_key_exists( 'image_b', $requests );
} ) )
->willReturn( [ 'image_a' => $image_result ] );
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
$cost_tracker->expects( $this->exactly( 1 ) )->method( 'log_image_generation' );
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' );
Functions\when( 'media_handle_sideload' )->justReturn( 42 );
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ], null );
$this->assertArrayHasKey( 'image_a_id', $result );
$this->assertArrayNotHasKey( 'image_b_id', $result );
}
/**
* Test graceful handling when batch generation throws.
*/
public function test_graceful_failure_when_batch_generation_throws(): void {
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
$provider->method( 'generate_image_batch' )->willThrowException( new \Exception( 'API timeout' ) );
$provider->method( 'generate_image' )->willThrowException( new \Exception( 'API timeout' ) );
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
@@ -206,185 +141,9 @@ class ImagePipelineTest extends BaseTestCase {
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ] );
// Should record error but return valid structure.
$this->assertIsArray( $result );
$this->assertNotEmpty( $result['errors'] );
$this->assertStringContainsString( 'API timeout', implode( ' ', $result['errors'] ) );
}
/**
* Test that a Logger::warning row is persisted when image_data carries an error.
*
* Covers Issue 2 from the image-mime-bug thread: previously the pipeline
* pushed the error into $result['errors'] silently, with no WARNING-level
* event_log row visible to ops. This regression-guards that a WARNING row
* now fires whether or not the outer catch in the caller triggers.
*/
public function test_partial_batch_failure_emits_warning_log_row(): void {
// Seed Logger's log_level so warnings actually get written.
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = [
'prautoblogger_image_enabled' => '1',
'prautoblogger_log_level' => 'info',
];
return $options[ $key ] ?? $default;
} );
$mock_wpdb = $this->create_mock_wpdb();
$captured_rows = [];
$mock_wpdb->method( 'insert' )->willReturnCallback(
function ( $table, $row ) use ( &$captured_rows ) {
$captured_rows[] = $row;
return 1;
}
);
$GLOBALS['wpdb'] = $mock_wpdb;
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
$provider->method( 'generate_image_batch' )->willReturn( [
'image_a' => [ 'error' => 'HTTP 400: flux-1-schnell is not a valid model ID' ],
] );
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
$pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ], null );
$warning_rows = array_filter(
$captured_rows,
static fn( $row ) => ( $row['level'] ?? '' ) === 'warning'
&& ( $row['context'] ?? '' ) === 'image_pipeline'
);
$this->assertNotEmpty(
$warning_rows,
'Expected at least one image_pipeline WARNING row when image_data has an error key.'
);
unset( $GLOBALS['wpdb'] );
}
/**
* NSFW-blocked slots should be retried once with a sanitized fallback
* prompt. Retry success replaces the error entry with a real image.
*/
public function test_nsfw_blocked_slot_is_retried_with_fallback_prompt(): void {
// NSFW retry is default on; explicit for clarity.
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = [
'prautoblogger_image_enabled' => '1',
'prautoblogger_image_nsfw_retry' => '1',
'prautoblogger_image_style_suffix' => 'Style: test suffix',
'prautoblogger_log_level' => 'info',
];
return $options[ $key ] ?? $default;
} );
$success_result = [
'bytes' => 'fake_image_bytes',
'mime_type' => 'image/png',
'width' => 1200,
'height' => 632,
'model' => 'flux-1-schnell',
'cost_usd' => 0.000834,
'latency_ms' => 1500,
];
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->method( 'estimate_cost' )->willReturn( 0.001 );
// First batch: NSFW. Second batch (retry for image_a): success.
$provider->expects( $this->exactly( 2 ) )
->method( 'generate_image_batch' )
->willReturnOnConsecutiveCalls(
[ 'image_a' => [ 'error' => 'NSFW', 'error_type' => 'nsfw_blocked' ] ],
[ 'image_a' => $success_result ]
);
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' );
Functions\when( 'media_handle_sideload' )->justReturn( 42 );
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test Article' ] );
// Retry succeeded — image_a_id set, no errors.
$this->assertArrayHasKey( 'image_a_id', $result );
$this->assertEmpty( $result['errors'] );
}
/**
* When `prautoblogger_image_nsfw_retry` is off, blocked slots stay
* blocked — no second HTTP call, pipeline publishes without that
* image. Failsafe for when the filter gets trigger-happy.
*/
public function test_nsfw_retry_skipped_when_setting_disabled(): void {
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = [
'prautoblogger_image_enabled' => '1',
'prautoblogger_image_nsfw_retry' => '0',
];
return $options[ $key ] ?? $default;
} );
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->method( 'estimate_cost' )->willReturn( 0.001 );
$provider->expects( $this->exactly( 1 ) )
->method( 'generate_image_batch' )
->willReturn( [
'image_a' => [ 'error' => 'NSFW', 'error_type' => 'nsfw_blocked' ],
] );
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test Article' ] );
$this->assertArrayNotHasKey( 'image_a_id', $result );
$this->assertNotEmpty( $result['errors'] );
}
/**
* Test graceful handling when one image in the batch returns an error.
*/
public function test_partial_batch_failure_handled_gracefully(): void {
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
$provider->method( 'generate_image_batch' )->willReturn( [
'image_a' => [
'bytes' => 'fake_image_data',
'mime_type' => 'image/png',
'width' => 1200,
'height' => 630,
'model' => 'test-model',
'cost_usd' => 0.05,
'latency_ms' => 2000,
],
'image_b' => [ 'error' => 'HTTP 429: rate limited' ],
] );
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
// Only Image A gets logged — Image B failed.
$cost_tracker->expects( $this->exactly( 1 ) )->method( 'log_image_generation' );
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' );
Functions\when( 'media_handle_sideload' )->justReturn( 42 );
$result = $pipeline->generate_and_attach_images(
1,
[ 'post_title' => 'Test' ],
[ 'title' => 'Source' ]
);
// Image A succeeds, Image B records an error.
$this->assertArrayHasKey( 'image_a_id', $result );
$this->assertArrayNotHasKey( 'image_b_id', $result );
$this->assertNotEmpty( $result['errors'] );
$this->assertStringContainsString( '429', implode( ' ', $result['errors'] ) );
}
}

View File

@@ -20,7 +20,7 @@ class ImagePromptBuilderTest extends BaseTestCase {
// Mock WordPress functions.
Functions\when( 'wp_strip_all_tags' )->alias( function ( $str ) {
return trim( strip_tags( $str ) );
return wp_strip_all_tags( $str );
} );
Functions\when( 'sanitize_text_field' )->returnArg();
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
@@ -30,32 +30,7 @@ class ImagePromptBuilderTest extends BaseTestCase {
}
/**
* Inject a mock OpenRouter provider into the Prompt_Builder via
* reflection so the rewrite path can be exercised without a real HTTP
* call. Used by the system-prompt override tests below.
*/
private function inject_llm(
\PRAutoBlogger_Image_Prompt_Builder $builder,
$mock_llm
): void {
$ref = new \ReflectionClass( \PRAutoBlogger_Image_Prompt_Builder::class );
$prop = $ref->getProperty( 'llm' );
$prop->setAccessible( true );
$prop->setValue( $builder, $mock_llm );
}
/**
* Test build_article_prompt() generates a prompt from article title.
*
* `build_article_prompt()` has returned `array{prompt: string, caption: string}`
* since v0.4.x. Destructure on receipt so the assertions run against the
* scene string, not the envelope.
*
* Note: this test does NOT assert on the article content substring. With no
* OpenRouter API key in the unit-test env, `rewrite_via_llm()` catches and
* falls back to `synthesize_visual_concepts_fallback()`, which reliably
* includes only the title — not the content. Content-based assertions
* belong in a test that mocks the LLM (see `test_rewrite_uses_setting_when_present`).
* Test build_article_prompt() generates a prompt from article title and content.
*/
public function test_build_article_prompt_from_content(): void {
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
@@ -65,13 +40,13 @@ class ImagePromptBuilderTest extends BaseTestCase {
'post_content' => '<p>Dragons are fascinating creatures. Learn the secrets.</p>',
];
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_article_prompt( $article_data );
$prompt = $builder->build_article_prompt( $article_data );
$this->assertStringContainsString( 'How to Train Your Dragon', $scene );
$this->assertStringContainsString( 'How to Train Your Dragon', $prompt );
$this->assertStringContainsString( 'Dragons are fascinating', $prompt );
// Style suffix should be appended.
$this->assertNotEmpty( $scene );
$this->assertGreaterThan( 50, strlen( $scene ) );
$this->assertIsString( $caption );
$this->assertNotEmpty( $prompt );
$this->assertGreaterThan( 50, strlen( $prompt ) );
}
/**
@@ -84,11 +59,10 @@ class ImagePromptBuilderTest extends BaseTestCase {
'post_title' => 'Minimal Article',
];
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_article_prompt( $article_data );
$prompt = $builder->build_article_prompt( $article_data );
$this->assertStringContainsString( 'Minimal Article', $scene );
$this->assertNotEmpty( $scene );
$this->assertIsString( $caption );
$this->assertStringContainsString( 'Minimal Article', $prompt );
$this->assertNotEmpty( $prompt );
}
/**
@@ -105,12 +79,11 @@ class ImagePromptBuilderTest extends BaseTestCase {
],
];
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_source_prompt( $source_data );
$prompt = $builder->build_source_prompt( $source_data );
$this->assertStringContainsString( 'Robot', $scene );
$this->assertStringContainsString( 'Dancing', $scene );
$this->assertNotEmpty( $scene );
$this->assertIsString( $caption );
$this->assertStringContainsString( 'Robot', $prompt );
$this->assertStringContainsString( 'Dancing', $prompt );
$this->assertNotEmpty( $prompt );
}
/**
@@ -123,18 +96,14 @@ class ImagePromptBuilderTest extends BaseTestCase {
'title' => 'Standalone Title',
];
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_source_prompt( $source_data );
$prompt = $builder->build_source_prompt( $source_data );
$this->assertStringContainsString( 'Standalone Title', $scene );
$this->assertNotEmpty( $scene );
$this->assertIsString( $caption );
$this->assertStringContainsString( 'Standalone Title', $prompt );
$this->assertNotEmpty( $prompt );
}
/**
* Test that prompts don't exceed reasonable length.
*
* The 500-char cap applies to the scene + style suffix (the `prompt`
* field), not the full array envelope.
*/
public function test_prompts_are_reasonably_sized(): void {
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
@@ -145,110 +114,9 @@ class ImagePromptBuilderTest extends BaseTestCase {
'post_content' => $long_content,
];
[ 'prompt' => $scene ] = $builder->build_article_prompt( $article_data );
$prompt = $builder->build_article_prompt( $article_data );
// Scene + style suffix should be under 500 chars.
$this->assertLessThan( 500, strlen( $scene ) );
}
/**
* When `prautoblogger_image_prompt_instructions` is non-empty, the
* rewriter LLM must receive that content as its system message —
* NOT the hardcoded REWRITER_SYSTEM_PROMPT constant.
*/
public function test_rewrite_uses_setting_when_present(): void {
$custom_system = 'CUSTOM-OVERRIDE: tell the LLM a very different thing.';
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) use ( $custom_system ) {
if ( 'prautoblogger_image_prompt_instructions' === $key ) {
return $custom_system;
}
if ( 'prautoblogger_image_style_suffix' === $key ) {
return 'Style: x';
}
return $default;
} );
$captured_messages = [];
$mock_llm = $this->createMock( \PRAutoBlogger_OpenRouter_Provider::class );
$mock_llm->method( 'send_chat_completion' )->willReturnCallback(
function ( $messages ) use ( &$captured_messages ) {
$captured_messages = $messages;
return [
'content' => "A scene.\n\n\"A caption.\"",
'prompt_tokens' => 10,
'completion_tokens' => 10,
];
}
);
$mock_llm->method( 'estimate_cost' )->willReturn( 0.0 );
// Stub $wpdb so Cost_Tracker::log_api_call inside rewrite_via_llm
// can insert without blowing up.
$mock_wpdb = $this->create_mock_wpdb();
$mock_wpdb->insert_id = 1;
$mock_wpdb->method( 'insert' )->willReturn( 1 );
$GLOBALS['wpdb'] = $mock_wpdb;
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
$this->inject_llm( $builder, $mock_llm );
$builder->build_article_prompt( [ 'post_title' => 'Test', 'post_content' => '<p>Body.</p>' ] );
unset( $GLOBALS['wpdb'] );
$this->assertNotEmpty( $captured_messages, 'Rewriter LLM was not called.' );
$this->assertSame( 'system', $captured_messages[0]['role'] ?? '' );
$this->assertSame( $custom_system, $captured_messages[0]['content'] ?? '' );
}
/**
* When the option is empty (or whitespace-only), the rewriter must
* fall back to REWRITER_SYSTEM_PROMPT — belt-and-braces so a blank
* save from the admin UI cannot brick image generation.
*/
public function test_rewrite_falls_back_to_default_when_setting_empty(): void {
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
if ( 'prautoblogger_image_prompt_instructions' === $key ) {
return ' '; // whitespace — should trigger fallback.
}
if ( 'prautoblogger_image_style_suffix' === $key ) {
return 'Style: x';
}
return $default;
} );
$captured_messages = [];
$mock_llm = $this->createMock( \PRAutoBlogger_OpenRouter_Provider::class );
$mock_llm->method( 'send_chat_completion' )->willReturnCallback(
function ( $messages ) use ( &$captured_messages ) {
$captured_messages = $messages;
return [
'content' => "A scene.\n\n\"A caption.\"",
'prompt_tokens' => 10,
'completion_tokens' => 10,
];
}
);
$mock_llm->method( 'estimate_cost' )->willReturn( 0.0 );
// Stub $wpdb so Cost_Tracker::log_api_call inside rewrite_via_llm
// can insert without blowing up.
$mock_wpdb = $this->create_mock_wpdb();
$mock_wpdb->insert_id = 1;
$mock_wpdb->method( 'insert' )->willReturn( 1 );
$GLOBALS['wpdb'] = $mock_wpdb;
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
$this->inject_llm( $builder, $mock_llm );
$builder->build_article_prompt( [ 'post_title' => 'Test', 'post_content' => '<p>Body.</p>' ] );
unset( $GLOBALS['wpdb'] );
$this->assertSame(
\PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
$captured_messages[0]['content'] ?? ''
);
// Prompt should be under 500 chars (concept + style suffix).
$this->assertLessThan( 500, strlen( $prompt ) );
}
}

View File

@@ -1,286 +0,0 @@
<?php
/**
* Tests for PRAutoBlogger_Research_Reaper.
*
* Exercises the five scenarios spec'd in convo thread
* research-cost-reaper/01-cto-handoff.md: meta-based reap, grace-period
* skip, 7-day stale delete, gen_log fallback when meta is missing, and
* idempotency on repeat runs.
*
* All DB work is mocked via a $wpdb stub — no real database round-trips.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class ResearchReaperTest extends BaseTestCase {
/**
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
*/
private $wpdb;
/**
* Captured INSERT rows (from amortize_research_costs delegating to $wpdb->insert).
*
* @var array<int, array<string, mixed>>
*/
private array $inserted_rows = [];
/**
* Captured DELETE calls: [$table, $where-array].
*
* @var array<int, array{0: string, 1: array<string, mixed>}>
*/
private array $deleted_rows = [];
/**
* Fixture: the rows get_results should hand back for the orphan scan.
*
* @var array<int, object>
*/
private array $orphan_fixtures = [];
/**
* Fixture: post IDs the reaper's fallback gen_log query should return.
*
* @var int[]
*/
private array $gen_log_post_ids = [];
protected function setUp(): void {
parent::setUp();
$this->inserted_rows = [];
$this->deleted_rows = [];
$this->orphan_fixtures = [];
$this->gen_log_post_ids = [];
// Bespoke $wpdb mock — BaseTestCase::create_mock_wpdb() doesn't
// stub `delete` or `get_col`, both of which the reaper + amortize
// call into. Wide-method mock keeps the test scope self-contained.
$this->wpdb = $this->getMockBuilder( \stdClass::class )
->addMethods( [ 'prepare', 'get_var', 'get_results', 'get_row', 'get_col', 'insert', 'delete', 'update', 'query' ] )
->getMock();
$this->wpdb->prefix = 'wp_';
$this->wpdb->insert_id = 1;
$this->wpdb->last_error = '';
$this->wpdb->prab_cost_logs = 'wp_prab_cost_logs';
$this->wpdb->method( 'insert' )->willReturnCallback(
function ( $table, $row ) {
$this->inserted_rows[] = array_merge( [ '__table' => $table ], $row );
return 1;
}
);
$this->wpdb->method( 'delete' )->willReturnCallback(
function ( $table, $where ) {
$this->deleted_rows[] = [ (string) $table, (array) $where ];
return 1;
}
);
// prepare() returns its args joined so we can pattern-match in get_* stubs.
$this->wpdb->method( 'prepare' )->willReturnCallback(
function ( $sql, ...$args ) {
return $sql . '|' . implode( '|', array_map( 'strval', $args ) );
}
);
// get_results routes by SQL fragment between the reaper's orphan scan
// and the occasional Logger insert probes.
$this->wpdb->method( 'get_results' )->willReturnCallback(
function ( $sql ) {
if ( false !== stripos( (string) $sql, "post_id IS NULL AND created_at <" ) ) {
return $this->orphan_fixtures;
}
return [];
}
);
// get_row serves amortize_research_costs's research-row lookup.
$this->wpdb->method( 'get_row' )->willReturnCallback(
function ( $sql ) {
foreach ( $this->orphan_fixtures as $o ) {
if ( false !== stripos( (string) $sql, (string) $o->run_id ) ) {
return (object) [
'id' => $o->id,
'estimated_cost' => $o->estimated_cost ?? 0.50,
'provider' => $o->provider ?? 'openrouter',
'model' => $o->model ?? 'gemini-flash',
'prompt_tokens' => $o->prompt_tokens ?? 1000,
'completion_tokens' => $o->completion_tokens ?? 500,
];
}
}
return null;
}
);
// get_col is the reaper's fallback "posts by run_id" and amortize's
// "distinct post_ids" query — both read from the same fixture.
$this->wpdb->method( 'get_col' )->willReturnCallback(
fn() => array_map( 'strval', $this->gen_log_post_ids )
);
$GLOBALS['wpdb'] = $this->wpdb;
$this->stub_get_option( [
'prautoblogger_log_level' => 'info',
] );
// Reaper uses get_posts to look up sibling posts via post_meta.
Functions\when( 'get_posts' )->justReturn( [] );
}
protected function tearDown(): void {
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/**
* Return only the `generation_log` inserts from the capture — filters out
* the `event_log` rows that Logger::instance()->info() writes as a side
* effect of the reaper + amortize's own progress logging.
*
* @return array<int, array<string, mixed>>
*/
private function gen_log_inserts(): array {
return array_values( array_filter(
$this->inserted_rows,
static fn( $row ) => false !== strpos( (string) ( $row['__table'] ?? '' ), 'generation_log' )
) );
}
/**
* Helper: seed the orphan list returned by the reaper's primary SELECT.
*
* @param array<int, array{id: int, run_id: string, created_at: string, estimated_cost?: float, provider?: string, model?: string, prompt_tokens?: int, completion_tokens?: int}> $orphans
*/
private function seed_orphans( array $orphans ): void {
$this->orphan_fixtures = array_map(
static fn( $r ) => (object) $r,
$orphans
);
}
/**
* Helper: set the post_ids the gen_log fallback query returns.
*
* @param int[] $post_ids
*/
private function seed_gen_log_posts_for_run( array $post_ids ): void {
$this->gen_log_post_ids = $post_ids;
}
/**
* 1. Reap succeeds via post_meta primary lookup.
*
* Orphan row is >1h old, `_prautoblogger_run_id` meta returns two
* post IDs, amortize inserts two attributed rows and deletes the
* original orphan.
*/
public function test_reaps_orphan_with_matching_run_id_posts(): void {
$old_ts = gmdate( 'Y-m-d H:i:s', time() - ( 2 * HOUR_IN_SECONDS ) );
$this->seed_orphans( [ [ 'id' => 99, 'run_id' => 'run-abc', 'created_at' => $old_ts, 'estimated_cost' => 0.40 ] ] );
// In the v0.8.1 world both sources agree. Meta is the reaper's gate;
// gen_log also has rows so `amortize_research_costs()` can do its
// own internal post-count query.
Functions\when( 'get_posts' )->alias(
static fn( $args ) => ( '_prautoblogger_run_id' === ( $args['meta_key'] ?? '' ) && 'run-abc' === ( $args['meta_value'] ?? '' ) )
? [ 101, 102 ]
: []
);
$this->seed_gen_log_posts_for_run( [ 101, 102 ] );
$stats = \PRAutoBlogger_Research_Reaper::reap();
$this->assertSame( 1, $stats['reaped'] );
$this->assertSame( 0, $stats['deleted'] );
$this->assertSame( 0, $stats['skipped'] );
$this->assertCount(
2,
$this->gen_log_inserts(),
'One amortized row per post should be inserted into the generation_log.'
);
// Original orphan deleted by amortize_research_costs.
$this->assertNotEmpty( array_filter( $this->deleted_rows, static fn( $d ) => ( $d[1]['id'] ?? null ) === 99 ) );
}
/**
* 2. Orphan within the 1-hour grace window is left alone — a live
* pipeline may still be in the middle of its own amortize step.
*/
public function test_skips_orphan_within_grace_period(): void {
// The primary SELECT filters on `created_at < (now - 1h)`, so a
// fresh orphan is excluded from the result set at the DB boundary.
// We simulate that by returning an empty orphan list.
$this->seed_orphans( [] );
Functions\when( 'get_posts' )->justReturn( [ 101, 102 ] );
$stats = \PRAutoBlogger_Research_Reaper::reap();
$this->assertSame( 0, $stats['reaped'] );
$this->assertSame( 0, $stats['deleted'] );
$this->assertSame( 0, $stats['skipped'] );
$this->assertEmpty( $this->inserted_rows );
$this->assertEmpty( $this->deleted_rows );
}
/**
* 3. Orphan older than 7 days with zero matching posts is deleted
* outright — research cost is sunk with nothing to attribute to.
*/
public function test_deletes_orphan_with_no_articles_after_7_days(): void {
$stale_ts = gmdate( 'Y-m-d H:i:s', time() - ( 8 * DAY_IN_SECONDS ) );
$this->seed_orphans( [ [ 'id' => 77, 'run_id' => 'run-dead', 'created_at' => $stale_ts ] ] );
// No posts via meta, no posts via gen_log.
Functions\when( 'get_posts' )->justReturn( [] );
$this->seed_gen_log_posts_for_run( [] );
$stats = \PRAutoBlogger_Research_Reaper::reap();
$this->assertSame( 0, $stats['reaped'] );
$this->assertSame( 1, $stats['deleted'] );
$this->assertNotEmpty( array_filter( $this->deleted_rows, static fn( $d ) => ( $d[1]['id'] ?? null ) === 77 ) );
}
/**
* 4. When `_prautoblogger_run_id` post_meta is absent (legacy posts
* predating v0.8.1), the reaper falls back to querying gen_log for
* distinct post_ids carrying the same run_id.
*/
public function test_falls_back_to_gen_log_when_post_meta_missing(): void {
$old_ts = gmdate( 'Y-m-d H:i:s', time() - ( 2 * HOUR_IN_SECONDS ) );
$this->seed_orphans( [ [ 'id' => 55, 'run_id' => 'run-legacy', 'created_at' => $old_ts, 'estimated_cost' => 0.30 ] ] );
// Meta lookup returns nothing.
Functions\when( 'get_posts' )->justReturn( [] );
// But gen_log has two posts with this run_id.
$this->seed_gen_log_posts_for_run( [ 201, 202 ] );
$stats = \PRAutoBlogger_Research_Reaper::reap();
$this->assertSame( 1, $stats['reaped'] );
$this->assertCount( 2, $this->gen_log_inserts() );
}
/**
* 5. A second reap run is a no-op — the first run deleted the orphan
* and inserted amortized per-article rows, so the scan returns empty.
*/
public function test_idempotent_on_repeat_run(): void {
// Simulate "already reaped" state: scan returns no orphans.
$this->seed_orphans( [] );
$stats_1 = \PRAutoBlogger_Research_Reaper::reap();
$stats_2 = \PRAutoBlogger_Research_Reaper::reap();
$this->assertSame( 0, $stats_1['reaped'] );
$this->assertSame( 0, $stats_2['reaped'] );
$this->assertEmpty( $this->inserted_rows );
$this->assertEmpty( $this->deleted_rows );
}
}

View File

@@ -25,8 +25,7 @@ class ContentRequestTest extends BaseTestCase {
1000,
2500,
'Technology and innovation',
[ 'politics', 'religion' ],
'Always open with a hook question.'
[ 'politics', 'religion' ]
);
$this->assertSame( $idea, $request->get_idea() );
@@ -36,7 +35,6 @@ class ContentRequestTest extends BaseTestCase {
$this->assertSame( 2500, $request->get_max_word_count() );
$this->assertSame( 'Technology and innovation', $request->get_niche_description() );
$this->assertSame( [ 'politics', 'religion' ], $request->get_topic_exclusions() );
$this->assertSame( 'Always open with a hook question.', $request->get_writing_instructions() );
}
/**
@@ -65,25 +63,6 @@ class ContentRequestTest extends BaseTestCase {
$this->assertIsArray( $request->get_topic_exclusions() );
}
/**
* Test writing_instructions defaults to empty string when omitted.
*/
public function test_writing_instructions_defaults_to_empty(): void {
$idea_data = $this->get_article_idea_fixture();
$idea = new \PRAutoBlogger_Article_Idea( $idea_data );
$request = new \PRAutoBlogger_Content_Request(
$idea,
'auto',
'professional',
1000,
2500,
'Tech'
);
$this->assertSame( '', $request->get_writing_instructions() );
}
/**
* Test with empty topic exclusions.
*/

View File

@@ -0,0 +1,432 @@
<?php
/**
* Tests for PRAutoBlogger_Cloudflare_Image_Provider.
*
* Validates the image provider interface implementation methods.
* All HTTP calls are mocked — no real API calls.
*
* @package PRAutoBlogger\Tests\Providers
*/
namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class CloudflareImageProviderTest extends BaseTestCase {
/**
* Fake PNG payload — bytes don't need to be a real PNG, just non-empty.
* Using a short string keeps failure messages readable.
*/
private const FAKE_IMAGE_BYTES = 'FAKE_PNG_BYTES';
protected function setUp(): void {
parent::setUp();
// PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL constant is defined in
// prautoblogger.php — define here for test isolation.
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) {
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'flux-1-schnell' );
}
$this->stub_get_option( [
'prautoblogger_cloudflare_ai_token' => '',
'prautoblogger_cloudflare_account_id' => 'test-account-uuid',
'prautoblogger_image_model' => 'flux-1-schnell',
'prautoblogger_log_level' => 'info',
] );
Functions\when( 'wp_salt' )->justReturn( 'test_salt_key_for_unit_tests' );
Functions\when( 'is_wp_error' )->justReturn( false );
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
}
/**
* Helper: configure the plaintext API token the provider will eventually
* see after decryption. We round-trip through the real Encryption class
* (rather than stubbing it) so this test catches any regressions in the
* encrypt → option → decrypt flow at the same time.
*/
private function with_token( string $plaintext ): void {
$ciphertext = \PRAutoBlogger_Encryption::encrypt( $plaintext );
$this->stub_get_option( [
'prautoblogger_cloudflare_ai_token' => $ciphertext,
'prautoblogger_cloudflare_account_id' => 'test-account-uuid',
'prautoblogger_image_model' => 'flux-1-schnell',
'prautoblogger_log_level' => 'info',
] );
}
/**
* Provider implements the interface.
*/
public function test_provider_implements_interface(): void {
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$this->assertInstanceOf( \PRAutoBlogger_Image_Provider_Interface::class, $provider );
}
/**
* get_provider_name returns the stable machine identifier.
*/
public function test_provider_name_is_stable_identifier(): void {
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$this->assertSame( 'cloudflare_workers_ai', $provider->get_provider_name() );
}
/**
* Dimension validation rejects inputs outside the [64, 2048] envelope
* before any HTTP call is made.
*/
public function test_generate_image_rejects_out_of_range_dimensions(): void {
$this->with_token( 'test-token' );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$this->expectException( \InvalidArgumentException::class );
$provider->generate_image( 'a prompt', 32, 1080 );
}
/**
* Empty prompts are rejected — an empty string burns budget on
* Workers AI for a deterministic "bad request" result.
*/
public function test_generate_image_rejects_empty_prompt(): void {
$this->with_token( 'test-token' );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$this->expectException( \InvalidArgumentException::class );
$provider->generate_image( ' ', 1080, 1080 );
}
/**
* Happy path — raw image bytes come back with expected metadata shape.
*/
public function test_generate_image_returns_raw_bytes_and_metadata(): void {
$this->with_token( 'test-token' );
Functions\when( 'wp_remote_post' )->justReturn( [
'body' => self::FAKE_IMAGE_BYTES,
'response' => [ 'code' => 200 ],
'headers' => [ 'content-type' => 'image/png' ],
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( self::FAKE_IMAGE_BYTES );
Functions\when( 'wp_remote_retrieve_header' )->alias(
function ( $_response, $name ) {
return 'content-type' === strtolower( (string) $name ) ? 'image/png' : '';
}
);
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$result = $provider->generate_image( 'a prompt', 1080, 1080, [ 'seed' => 42 ] );
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
$this->assertSame( 'image/png', $result['mime_type'] );
$this->assertSame( 1080, $result['width'] );
$this->assertSame( 1080, $result['height'] );
$this->assertSame( 42, $result['seed'] );
$this->assertSame( '@cf/black-forest-labs/flux-1-schnell', $result['model'] );
$this->assertGreaterThan( 0.0, $result['cost_usd'] );
$this->assertGreaterThanOrEqual( 0, $result['latency_ms'] );
}
/**
* Happy path where Workers AI wraps the image in a JSON envelope with
* base64-encoded bytes under `result.image`. The provider must decode
* and return the raw bytes so the pipeline never has to branch.
*/
public function test_generate_image_decodes_json_envelope(): void {
$this->with_token( 'test-token' );
$encoded = base64_encode( self::FAKE_IMAGE_BYTES );
$body = wp_json_encode( [ 'result' => [ 'image' => $encoded ], 'success' => true ] );
Functions\when( 'wp_remote_post' )->justReturn( [
'body' => $body,
'response' => [ 'code' => 200 ],
'headers' => [ 'content-type' => 'application/json' ],
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( $body );
Functions\when( 'wp_remote_retrieve_header' )->alias(
function ( $_response, $name ) {
return 'content-type' === strtolower( (string) $name ) ? 'application/json' : '';
}
);
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$result = $provider->generate_image( 'a prompt', 600, 600 );
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
$this->assertSame( 'image/png', $result['mime_type'] );
}
/**
* A 401 must NOT retry — auth errors are deterministic and retrying
* burns budget. The provider should throw on the first response.
*
* We can't assert "exactly one HTTP call" without injecting a call
* counter, so we assert that the throw message contains the status.
*/
public function test_generate_image_fails_loudly_on_401(): void {
$this->with_token( 'test-token' );
Functions\when( 'wp_remote_post' )->justReturn( [
'body' => '{"errors":[{"message":"bad token"}]}',
'response' => [ 'code' => 401 ],
'headers' => [],
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 401 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"bad token"}]}' );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
try {
$provider->generate_image( 'a prompt', 1080, 1080 );
$this->fail( 'Expected RuntimeException for HTTP 401.' );
} catch ( \RuntimeException $e ) {
$this->assertStringContainsString( '401', $e->getMessage() );
}
}
/**
* Response shape must be one of: image/* bytes OR JSON envelope with
* base64 image. Anything else is a contract breach — fail loudly.
*/
public function test_generate_image_throws_on_unexpected_response_shape(): void {
$this->with_token( 'test-token' );
Functions\when( 'wp_remote_post' )->justReturn( [
'body' => '<!doctype html><html><body>oops</body></html>',
'response' => [ 'code' => 200 ],
'headers' => [ 'content-type' => 'text/html' ],
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '<!doctype html><html><body>oops</body></html>' );
Functions\when( 'wp_remote_retrieve_header' )->alias(
function ( $_response, $name ) {
return 'content-type' === strtolower( (string) $name ) ? 'text/html' : '';
}
);
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$this->expectException( \RuntimeException::class );
$provider->generate_image( 'a prompt', 1080, 1080 );
}
/**
* Cost estimation is positive for in-range dimensions and scales with
* area — a 1080² image costs more than a 600² image on the same model.
*/
public function test_estimate_cost_scales_with_area(): void {
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$small = $provider->estimate_cost( 600, 600, [ 'model' => 'flux-1-schnell' ] );
$large = $provider->estimate_cost( 1080, 1080, [ 'model' => 'flux-1-schnell' ] );
$this->assertGreaterThan( 0.0, $small );
$this->assertGreaterThan( $small, $large );
}
/**
* [dev] is meaningfully pricier than [schnell] at the same dimensions —
* the ~4x multiplier is the reason we default to schnell.
*/
public function test_estimate_cost_dev_more_expensive_than_schnell(): void {
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$schnell = $provider->estimate_cost( 1080, 1080, [ 'model' => 'flux-1-schnell' ] );
$dev = $provider->estimate_cost( 1080, 1080, [ 'model' => 'flux-1-dev' ] );
$this->assertGreaterThan( $schnell, $dev );
}
/**
* validate_credentials_detailed reports 'token_empty' debug tag when
* no token is configured, without making an HTTP call.
*/
public function test_validate_credentials_reports_missing_token(): void {
// Default setUp leaves token empty.
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$result = $provider->validate_credentials_detailed();
$this->assertSame( 'error', $result['status'] );
$this->assertSame( 'token_empty', $result['debug'] );
}
/**
* validate_credentials_detailed reports 'account_id_empty' when the
* account ID option is blank, without touching the network.
*/
public function test_validate_credentials_reports_missing_account_id(): void {
$this->stub_get_option( [
'prautoblogger_cloudflare_ai_token' => 'not-empty',
'prautoblogger_cloudflare_account_id' => '',
'prautoblogger_log_level' => 'info',
] );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$result = $provider->validate_credentials_detailed();
$this->assertSame( 'error', $result['status'] );
$this->assertSame( 'account_id_empty', $result['debug'] );
}
/**
* validate_credentials_detailed must never invoke the generation endpoint.
* Asserted by tracking every wp_remote_post call: a real-image call is
* the only thing that POSTs to `/ai/run/...`. Validator uses GET on
* `/ai/models/search`, which is the free read-only path.
*/
public function test_validate_credentials_never_calls_generate_endpoint(): void {
$this->with_token( 'test-token' );
$post_calls = [];
Functions\when( 'wp_remote_post' )->alias(
function ( $url, $args ) use ( &$post_calls ) {
$post_calls[] = $url;
return [ 'body' => '', 'response' => [ 'code' => 500 ] ];
}
);
Functions\when( 'wp_remote_get' )->justReturn( [
'body' => '{"result":[]}',
'response' => [ 'code' => 200 ],
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"result":[]}' );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$provider->validate_credentials_detailed();
$this->assertSame( [], $post_calls, 'validate_credentials_detailed() must not POST to /ai/run/ endpoints.' );
}
/**
* Retry path: a 500 followed by a 200 must yield the 200 payload.
* Exercises the exponential-backoff loop end-to-end without sleeping
* (bootstrap sets PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS to 0).
*/
public function test_generate_image_retries_on_5xx_then_succeeds(): void {
$this->with_token( 'test-token' );
$response_codes = [ 500, 200 ];
$response_bodies = [ 'upstream hiccup', self::FAKE_IMAGE_BYTES ];
Functions\when( 'wp_remote_post' )->justReturn( [
'body' => self::FAKE_IMAGE_BYTES,
'response' => [ 'code' => 200 ],
'headers' => [ 'content-type' => 'image/png' ],
] );
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
function () use ( &$response_codes ) {
return array_shift( $response_codes ) ?? 200;
}
);
Functions\when( 'wp_remote_retrieve_body' )->alias(
function () use ( &$response_bodies ) {
return array_shift( $response_bodies ) ?? self::FAKE_IMAGE_BYTES;
}
);
// wp_remote_retrieve_header is called only on the success attempt —
// the 5xx branch returns before any header lookup. One justReturn
// for the content-type is enough; retry-after lookups accept the
// same stub and will coerce to (int) 0.
Functions\when( 'wp_remote_retrieve_header' )->justReturn( 'image/png' );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$result = $provider->generate_image( 'a prompt', 1080, 1080 );
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
}
/**
* Retry exhaustion: persistent 502s must throw after MAX_RETRIES attempts
* rather than hanging indefinitely.
*/
public function test_generate_image_fails_after_max_retries_on_persistent_5xx(): void {
$this->with_token( 'test-token' );
$call_count = 0;
Functions\when( 'wp_remote_post' )->alias(
function () use ( &$call_count ) {
++$call_count;
return [
'body' => 'bad gateway',
'response' => [ 'code' => 502 ],
'headers' => [],
];
}
);
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 502 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( 'bad gateway' );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
try {
$provider->generate_image( 'a prompt', 1080, 1080 );
$this->fail( 'Expected RuntimeException after retry exhaustion.' );
} catch ( \RuntimeException $e ) {
$this->assertStringContainsString( '502', $e->getMessage() );
}
$this->assertSame(
PRAUTOBLOGGER_MAX_RETRIES,
$call_count,
'Provider must attempt exactly PRAUTOBLOGGER_MAX_RETRIES times before giving up.'
);
}
/**
* 429 triggers the same retry loop as 5xx (rate-limited, not deterministic
* error). The provider must retry and eventually succeed if the second
* attempt returns 200.
*/
public function test_generate_image_retries_on_429(): void {
$this->with_token( 'test-token' );
$response_codes = [ 429, 200 ];
$response_bodies = [ 'slow down', self::FAKE_IMAGE_BYTES ];
Functions\when( 'wp_remote_post' )->justReturn( [
'body' => self::FAKE_IMAGE_BYTES,
'response' => [ 'code' => 200 ],
'headers' => [ 'content-type' => 'image/png' ],
] );
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
function () use ( &$response_codes ) {
return array_shift( $response_codes ) ?? 200;
}
);
Functions\when( 'wp_remote_retrieve_body' )->alias(
function () use ( &$response_bodies ) {
return array_shift( $response_bodies ) ?? self::FAKE_IMAGE_BYTES;
}
);
// Content-type is only read on the success attempt; retry-after is
// read on the 429 attempt and (int)-coerces anything non-numeric
// back to 0, falling through to exponential backoff.
Functions\when( 'wp_remote_retrieve_header' )->justReturn( 'image/png' );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$result = $provider->generate_image( 'a prompt', 1080, 1080 );
$this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] );
}
/**
* validate_credentials_detailed returns 'ok' on a 200 from the models
* listing endpoint.
*/
public function test_validate_credentials_ok_on_200(): void {
$this->with_token( 'test-token' );
Functions\when( 'wp_remote_get' )->justReturn( [
'body' => '{"result":[{"id":"@cf/black-forest-labs/flux-1-schnell"}]}',
'response' => [ 'code' => 200 ],
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"result":[]}' );
$provider = new \PRAutoBlogger_Cloudflare_Image_Provider();
$result = $provider->validate_credentials_detailed();
$this->assertSame( 'ok', $result['status'] );
}
}

View File

@@ -1,68 +0,0 @@
<?php
/**
* Tests for PRAutoBlogger_OpenRouter_Image_Pricing.
*
* Validates model resolution fallback logic and per-image cost estimation.
*
* @package PRAutoBlogger\Tests\Providers
*/
namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class OpenRouterImagePricingTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = [];
return $options[ $key ] ?? $default;
} );
}
/** Empty hint resolves to the PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL constant. */
public function test_resolve_model_empty_hint_uses_default(): void {
$pricing = new \PRAutoBlogger_OpenRouter_Image_Pricing();
$model = $pricing->resolve_model( '' );
$this->assertSame( PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, $model );
}
/** Explicit hint resolves to itself, regardless of what default is set. */
public function test_resolve_model_valid_hint(): void {
$pricing = new \PRAutoBlogger_OpenRouter_Image_Pricing();
$model = $pricing->resolve_model( 'openai/gpt-5-image' );
$this->assertSame( 'openai/gpt-5-image', $model );
}
/** Gemini Flash Image costs $0.005/image. */
public function test_estimate_cost_gemini_flash(): void {
$pricing = new \PRAutoBlogger_OpenRouter_Image_Pricing();
$cost = $pricing->estimate_cost( 1200, 632, 'google/gemini-2.5-flash-image' );
$this->assertEqualsWithDelta( 0.005, $cost, 0.000001 );
}
/** GPT-5 Image costs $0.08/image. */
public function test_estimate_cost_gpt5_image(): void {
$pricing = new \PRAutoBlogger_OpenRouter_Image_Pricing();
$cost = $pricing->estimate_cost( 1200, 632, 'openai/gpt-5-image' );
$this->assertEqualsWithDelta( 0.08, $cost, 0.000001 );
}
/** Unknown model falls back to conservative $0.05 estimate. */
public function test_estimate_cost_unknown_model(): void {
$pricing = new \PRAutoBlogger_OpenRouter_Image_Pricing();
$cost = $pricing->estimate_cost( 1200, 632, 'unknown/model' );
$this->assertEqualsWithDelta( 0.05, $cost, 0.000001 );
}
/** get_model_costs returns a non-empty map with known models. */
public function test_get_model_costs(): void {
$costs = \PRAutoBlogger_OpenRouter_Image_Pricing::get_model_costs();
$this->assertIsArray( $costs );
$this->assertArrayHasKey( 'google/gemini-2.5-flash-image', $costs );
$this->assertArrayHasKey( 'openai/gpt-5-image', $costs );
$this->assertGreaterThan( 0, count( $costs ) );
}
}

View File

@@ -1,154 +0,0 @@
<?php
/**
* Tests for PRAutoBlogger_OpenRouter_Image_Provider.
*
* Validates provider interface compliance, response parsing, and error handling.
* All HTTP calls are mocked — no real API calls.
*
* @package PRAutoBlogger\Tests\Providers
*/
namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class OpenRouterImageProviderTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
// Provide a valid encrypted API key.
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = [
'prautoblogger_openrouter_api_key' => 'enc:fake-encrypted-key',
'prautoblogger_image_model' => 'black-forest-labs/flux-2-pro',
'prautoblogger_ai_gateway_base_url' => '',
];
return $options[ $key ] ?? $default;
} );
Functions\when( 'is_wp_error' )->alias( function ( $thing ) {
return $thing instanceof \WP_Error;
} );
Functions\when( 'esc_html' )->returnArg();
Functions\when( 'esc_html__' )->returnArg();
// Mock encryption: decrypt returns a valid sk-or- key.
if ( ! class_exists( 'PRAutoBlogger_Encryption' ) ) {
eval( '
class PRAutoBlogger_Encryption {
public static function is_encrypted( $v ) { return 0 === strpos( $v, "enc:" ); }
public static function decrypt( $v ) { return "sk-or-test-key-123"; }
public static function encrypt( $v ) { return "enc:" . $v; }
}
' );
}
}
/** Provider name matches expected identifier. */
public function test_get_provider_name(): void {
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
$this->assertSame( 'openrouter', $provider->get_provider_name() );
}
/** estimate_cost returns a positive float. */
public function test_estimate_cost_returns_float(): void {
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
$cost = $provider->estimate_cost( 1200, 632 );
$this->assertIsFloat( $cost );
$this->assertGreaterThan( 0, $cost );
}
/** Empty prompt throws InvalidArgumentException. */
public function test_generate_image_empty_prompt_throws(): void {
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
$this->expectException( \InvalidArgumentException::class );
$provider->generate_image( '', 1200, 632 );
}
/** Dimension below minimum throws InvalidArgumentException. */
public function test_generate_image_tiny_dimensions_throws(): void {
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
$this->expectException( \InvalidArgumentException::class );
$provider->generate_image( 'A test prompt', 10, 10 );
}
/** Dimension above maximum throws InvalidArgumentException. */
public function test_generate_image_huge_dimensions_throws(): void {
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
$this->expectException( \InvalidArgumentException::class );
$provider->generate_image( 'A test prompt', 5000, 5000 );
}
/** Successful generation returns correct array structure. */
public function test_generate_image_success(): void {
// Build a fake OpenRouter response with base64 image data.
$fake_bytes = 'fake-image-data';
$b64 = base64_encode( $fake_bytes );
$data_url = 'data:image/png;base64,' . $b64;
$body = wp_json_encode( [
'choices' => [ [
'message' => [
'role' => 'assistant',
'content' => '',
'images' => [ [
'type' => 'image_url',
'image_url' => [ 'url' => $data_url ],
] ],
],
] ],
] );
Functions\when( 'wp_remote_post' )->justReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => $body,
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( $body );
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
Functions\when( 'wp_parse_url' )->alias( function ( $url, $component = -1 ) {
return parse_url( $url, $component );
} );
Functions\when( 'add_action' )->justReturn( true );
Functions\when( 'remove_action' )->justReturn( true );
Functions\when( 'home_url' )->justReturn( 'https://peptiderepo.com' );
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
$result = $provider->generate_image( 'A peptide research infographic', 1200, 632 );
$this->assertArrayHasKey( 'bytes', $result );
$this->assertArrayHasKey( 'mime_type', $result );
$this->assertArrayHasKey( 'model', $result );
$this->assertArrayHasKey( 'cost_usd', $result );
$this->assertArrayHasKey( 'latency_ms', $result );
$this->assertSame( $fake_bytes, $result['bytes'] );
$this->assertSame( 'image/png', $result['mime_type'] );
$this->assertSame( 1200, $result['width'] );
$this->assertSame( 632, $result['height'] );
}
/** 4xx error throws RuntimeException. */
public function test_generate_image_4xx_throws(): void {
Functions\when( 'wp_remote_post' )->justReturn( [
'response' => [ 'code' => 401 ],
'headers' => [],
'body' => '{"error":"unauthorized"}',
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 401 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"error":"unauthorized"}' );
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
Functions\when( 'wp_parse_url' )->alias( function ( $url, $component = -1 ) {
return parse_url( $url, $component );
} );
Functions\when( 'add_action' )->justReturn( true );
Functions\when( 'remove_action' )->justReturn( true );
Functions\when( 'home_url' )->justReturn( 'https://peptiderepo.com' );
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
$this->expectException( \RuntimeException::class );
$this->expectExceptionMessage( 'HTTP 401' );
$provider->generate_image( 'A test prompt', 1200, 632 );
}
}

View File

@@ -48,9 +48,6 @@ class OpenRouterProviderTest extends BaseTestCase {
);
Functions\when( 'set_transient' )->justReturn( true );
// Stub URL parsing.
Functions\when( 'wp_parse_url' )->alias( 'parse_url' );
// Stub HTTP functions for API calls.
Functions\when( 'wp_remote_post' )->justReturn( [
'body' => '{"choices":[{"message":{"content":"test"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}',

Some files were not shown because too many files have changed in this diff Show More