Compare commits

..

5 Commits

Author SHA1 Message Date
peptiderepo
a7026e2f0a chore(v0.8.0): version bump + CHANGELOG + ARCHITECTURE.md for image bundle
Bump plugin header and PRAUTOBLOGGER_VERSION 0.7.3 → 0.8.0. Minor bump
because Commit D removes the Image Provider dropdown from admin (a
UX-visible change), not a patch.

CHANGELOG:
- Changed: collapsed Image Provider + Model dropdowns; provider derived.
- Added: Image Prompt Instructions setting; Retry NSFW-Blocked Images
  setting.
- Fixed: silent per-image failures in the event log.

ARCHITECTURE.md:
- Stage 6b (Image Pipeline) callout now describes provider derivation
  and the NSFW-retry fallback path.
- wp_options table lists the four new/changed keys
  (`prautoblogger_image_provider` as derived, new
  `prautoblogger_image_prompt_instructions`,
  `prautoblogger_image_nsfw_retry`, and the one-shot migration flag
  `prautoblogger_migrated_image_provider_v080`).

DB schema unchanged — no PRAUTOBLOGGER_DB_VERSION bump needed.

Final commit of the v0.8.0 image bundle (thread image-mime-bug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 01:18:03 +07:00
peptiderepo
52a8ffbd9e feat(images): collapse Image Provider + Image Model into a single dropdown
Until now, Image Provider and Image Model were two independent selects
in the admin UI. That let the CEO set Model=flux-1-schnell while
Provider was still openrouter — OpenRouter has no such model so every
image gen on posts 650/657 on 2026-04-20 returned HTTP 400, and the
article published without a featured image.

v0.8.0 removes the Image Provider field from the admin UI entirely.
The provider is now derived from the model's entry in the registry
every time the model is saved. A new
PRAutoBlogger_Image_Model_Registry::provider_for() helper centralises
the mapping, so admin save, the on-upgrade migration, and future
callers all read from one place.

On save, PRAutoBlogger_Admin_Page::sanitize_image_model() validates
the submitted slug against the registry:
- Known slug: persist it, and update `prautoblogger_image_provider` to
  the paired provider as a side-effect.
- Unknown slug: add_settings_error with a clear message, and return
  the previously-saved value so the site stays on a working model.

A one-shot v0.8.0 migration in
PRAutoBlogger::on_check_db_version() derives the provider from the
currently-saved model on first admin_init after the plugin is updated.
This auto-heals sites that were running mismatched pairs before the
upgrade, without requiring the admin to re-open settings.

`prautoblogger_image_provider` option key is preserved — all existing
reads in the pipeline, cost tracker, and validators continue to work
unchanged.

Tests (Services/ImageModelRegistryTest):

- Every registry entry has an id and a provider in {openrouter, cloudflare}.
- provider_for() returns the paired provider for a known slug.
- provider_for() returns '' for unknown/empty slugs.

Cost impact: none.

Migration idempotent via the `prautoblogger_migrated_image_provider_v080`
flag. Uninstall sweeps it along with every other plugin option.

Part of the v0.8.0 image bundle (thread image-mime-bug, Issue 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 01:16:30 +07:00
peptiderepo
0505e50e85 feat(images): expose rewriter system prompt as admin setting
CEO feedback: every generated image is anchoring on the cartoon
peptide-with-arms-and-legs character because the hardcoded rewriter
system prompt both lists "anthropomorphized molecules" first in its
primer and ships a few-shot example that literally shows one. The ask
is to give CEO the lever without a code change.

Adds `prautoblogger_image_prompt_instructions` (textarea) in the
Images section. Default value is the existing REWRITER_SYSTEM_PROMPT
constant verbatim, so behaviour is unchanged on upgrade until someone
edits it. `Image_Prompt_Builder::rewrite_via_llm()` reads the option
at call time via a new `resolve_system_prompt()` helper and falls back
to the constant if the option is blank or whitespace-only, so a saved
empty value cannot brick image generation.

Const visibility flipped from `private` to `public` so the settings
field can reference it directly as the field default.

Tests:

- `test_rewrite_uses_setting_when_present` — option set, assert the
  LLM's system message equals the option content.
- `test_rewrite_falls_back_to_default_when_setting_empty` — option
  whitespace, assert the LLM's system message equals the constant.

Uninstall: new option covered by the existing prautoblogger_% LIKE
sweep in uninstall.php.

Cost impact: none by default. Future CEO edits will change prompt
length; current default is ~180 tokens, budget impact linear from
there.

Part of the v0.8.0 image bundle (thread image-mime-bug, message 03).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 01:11:15 +07:00
peptiderepo
fb1451c4dc feat(images): retry Cloudflare NSFW-blocked prompts with a sanitized fallback
Cloudflare's Workers AI FLUX.1 schnell content filter sometimes rejects
peptide-comic prompts as NSFW (HTTP 400, errors[].code === 3030). On
2026-04-20 this produced a silent publish-without-featured-image for
post #650 ("Semaglutide vs Tirzepatide") — the LLM-rewritten scene
tripped the filter and we had no recovery path.

New behaviour: on a 3030 response, the Cloudflare provider throws a
typed PRAutoBlogger_Image_NSFW_Blocked exception. The batch wrapper
catches it and tags the per-slot result with
`error_type: nsfw_blocked`. Image_Pipeline hands the results to a new
PRAutoBlogger_Image_NSFW_Retry helper, which reruns the blocked slot
once with a sanitized fallback prompt (title + style suffix, no LLM
rewrite). On success the retry result replaces the error entry; on a
second block, a WARNING is logged and the pipeline publishes without
that image — same terminal behaviour we had before, just explicit.

Files:

- New providers/class-image-nsfw-blocked.php — typed exception.
- providers/class-cloudflare-image-support.php — is_nsfw_error() +
  throw_if_nsfw() helpers.
- providers/class-cloudflare-image-provider.php — calls throw_if_nsfw
  inside the 4xx branch; batch catches the typed exception and marks
  the result with error_type.
- core/class-image-prompt-builder.php — new public build_fallback_prompt
  shim so the retry layer can reach the rule-based fallback without
  going through the LLM path.
- New core/class-image-nsfw-retry.php — single-shot retry helper.
- core/class-image-pipeline.php — invokes the retry between batch
  completion and per-slot processing.
- admin/class-settings-fields-extended.php — new toggle
  `prautoblogger_image_nsfw_retry` (default on) in the Images section.

Tests:

- CloudflareImageProviderTest — 3030 response throws NSFW exception;
  batch tags the per-slot result with `error_type: nsfw_blocked`.
- ImagePipelineTest — retry succeeds when provider returns a good
  result on the second call; retry is skipped when the setting is off.

Cost impact: worst-case an extra Cloudflare call per NSFW-blocked slot
(~$0.000834). Current incidence ~2% of generations; ~+$0.00002 per
generation amortized. Immaterial.

Uninstall: new option covered by the existing prautoblogger_% LIKE
sweep in uninstall.php.

Part of the v0.8.0 image bundle (issues from thread image-mime-bug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 01:06:59 +07:00
peptiderepo
a3ed663bae fix(image-pipeline): surface per-image failures in the event log
Two gaps made posts 650/657 appear to silently "lose" their featured
images on 2026-04-20:

1. The batch provider tagged each failed key with ['error' => ...] but
   the ERROR log line it emitted didn't identify which key failed, so
   operators couldn't tell image_a from image_b in the event log when
   one of two parallel requests died.

2. Image_Pipeline::process_image_a/_b folded the per-key error into
   $result['errors'] and returned silently, with no WARNING row. Ops
   only learned about the miss when the outer catch in the caller fired
   — and on the 2026-04-20 incident that outer catch didn't fire, so
   nothing was logged at all except an unrelated pricing warning.

Fix:

- class-open-router-image-batch.php: collect_result() takes the request
  key as its first argument and every Logger::error message reads
  'OpenRouter batch image gen <class> for key "<key>": <detail>'.
  Matches the shape of OpenRouter_Image_Support::log_retry.

- class-image-pipeline.php: process_image_a and process_image_b now
  emit a Logger::warning('image_pipeline') row whenever $image_data is
  missing or carries an error key, in addition to pushing to
  $result['errors']. The warning fires whether or not the outer catch
  runs.

Test: ImagePipelineTest::test_partial_batch_failure_emits_warning_log_row
mocks $wpdb, runs the pipeline with image_a erroring, and asserts at
least one warning/image_pipeline row was inserted.

No behavior change beyond logging; cost impact zero.

Part of the v0.8.0 image bundle (issues from thread image-mime-bug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 00:59:54 +07:00
108 changed files with 2747 additions and 5403 deletions

View File

@@ -44,7 +44,7 @@ jobs:
phpcs:
name: PHPCS (WordPress standards)
runs-on: ubuntu-latest
continue-on-error: false # now strict after PHPCS cleanup in v0.8.3
continue-on-error: true # non-blocking for now — see CONVENTIONS.md
steps:
- uses: actions/checkout@v4
- name: Setup PHP
@@ -66,7 +66,7 @@ jobs:
"phpcsstandards/phpcsextra:^1.0"
- name: Run PHPCS
run: |
phpcs --standard=phpcs.xml \
phpcs --standard=WordPress-Core \
--extensions=php \
--ignore=vendor/*,tests/*,node_modules/* \
--warning-severity=0 \

View File

@@ -208,8 +208,7 @@ jobs:
1. Assess whether the change is safe to merge and deploy to production (peptiderepo.com on Hostinger WordPress).
2. Flag security issues, cost traps (paid AI API usage), performance regressions, missing input sanitization, missing output escaping, hardcoded secrets, broken migrations, or violations of the repo's CONVENTIONS.md.
3. Check that the PR follows the project's git workflow: branch naming (claude/<scope>-<date>), Agent-Session trailer in commits, PR description covers what/why/risk/test-plan.
4. CTO app-map freshness. If this PR modifies any of: (a) the admin settings surface (register_setting, add_submenu_page, add_options_page, settings field labels, option keys, types, defaults); (b) the file/class structure (any file added, renamed, split per the 300-line rule, or materially restructured); or (c) the data flow (new external API client, new hook/filter fired publicly, new scheduled job, or changed class responsibilities) — then the PR description MUST include a line referencing an accompanying update to `Peptide Repo CTO/app-maps/<repo-slug>.md`. The CTO workspace is a separate repo, so CI cannot verify the doc update mechanically — you must inspect the PR description for an explicit mention. If the description is silent despite a triggering change, raise this as a MAJOR concern (not blocking) so the author can add the mention or open a follow-up PR on the CTO workspace.
5. Be concise and direct. No pleasantries. Call out concrete line-level issues when possible.
4. Be concise and direct. No pleasantries. Call out concrete line-level issues when possible.
You MUST respond by calling the submit_review function. Do not write prose outside the tool call.

View File

@@ -171,17 +171,17 @@ prautoblogger/
│ │ ├── 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
│ │ ├── class-cloudflare-image-provider.php # Cloudflare Workers AI (sequential batch fallback)
│ │ ├── 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 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)
│ │ └── (Phase 3: class-cloudflare-workers-ai-model-registry.php)
│ │
│ ├── frontend/
│ │ ├── class-article-typography.php # Inline CSS for font, size, table borders on generated posts
@@ -435,6 +435,8 @@ 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_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 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 |
@@ -476,7 +478,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,10 +521,7 @@ 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.
@@ -531,24 +530,14 @@ Reddit rejected our OAuth API application (April 2026). We initially switched to
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.
### #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.
---
### #20: PHPUnit test infrastructure + WordPress-Core PHPCS compliance (v0.10.1)
Tests use Brain\Monkey for WordPress function mocking (no database required). The BaseTestCase singleton handles setup/teardown of Monkey stubs and provides fixture helpers for common data shapes (SourceData, ArticleIdea, GenerationLog, etc.). All WordPress functions used by the plugin are stubbed in BaseTestCase::setUp() so individual test classes only override what they need. In v0.10.1, missing stubs for `post_type_exists()` were added to support PeptideLinker's PR-Core guards. All 1,362 WordPress-Core coding-standards violations (short array syntax, line length, spacing) were auto-fixed via phpcbf and the CI gate was flipped to strict mode — PHPCS failures now block CI. This prevents future regressions on the code style front.
## Cross-System LLM Budget Coordination
PRAutoBlogger and Peptide News both call OpenRouter and may share a single API key / billing account. Their combined spend should be considered when setting per-plugin budgets.

View File

@@ -5,204 +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.1] — 2026-04-23
### Fixed
- **PHPCS: closed the last 26 WordPress-Core residuals** (15 short ternaries rewritten, 11 targeted ignores on false-positive ternary-with-prepare in ideas-browser + logger).
- **Tests: OpenRouterImageProviderTest now round-trips through real Encryption class.**
setUp was using a dead `eval()` mock that never installed because the real `PRAutoBlogger_Encryption`
class was autoloaded first. Tests now call `PRAutoBlogger_Encryption::encrypt()` directly,
leveraging the BaseTestCase-stubbed `wp_salt()` for deterministic round-trip decryption.
Fixes both `test_generate_image_success` and `test_generate_image_4xx_throws`.
- **Tests: add missing `post_type_exists()` stub to BaseTestCase.**
When PR Core is not active, PeptideLinker guards calls with `post_type_exists()`.
This WordPress function was not mocked in the base test setup, causing 8 PublisherTest errors.
### Changed
- **PHPCS: backlog substantially reduced (1,292 → 28 violations).**
Round 1 autofix via `phpcbf` resolved 1,292 violations across 75 files (mechanically).
Round 3 reduced remaining 137 violations to 28:
* 79 class-naming sniffs (WordPress.Files.FileName) — excluded in phpcs.xml; architectural decision to use short class names (class-logger.php) instead of fully-qualified names for readability.
* 56 `$wpdb->prepare()` violations (WordPress.DB.PreparedSQL) — excluded in phpcs.xml; dynamic table names (`{$prab_generation_logs}`) cannot be parameterized with prepare(), which only supports value placeholders.
* 28 remaining violations (other sniffs) — mostly in class-logger.php, class-ideas-browser.php; deferred for future cleanup.
Excluded sniffs are documented in phpcs.xml with architectural justifications.
- **CI: PHPCS gate now strict.** Changed `continue-on-error: true → false` in `.github/workflows/ci.yml`.
PHPCS failures will now block CI, preventing style regression.
## [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

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Displays admin notices for onboarding, errors, and budget warnings.
*
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_notices`.
@@ -64,13 +62,11 @@ class PRAutoBlogger_Admin_Notices {
} elseif ( $utilization >= 80.0 ) {
printf(
'<div class="notice notice-warning"><p>%s</p></div>',
esc_html(
sprintf(
esc_html( sprintf(
/* translators: %s: budget utilization percentage */
__( 'PRAutoBlogger: Monthly API budget is at %.0f%%. Consider increasing the budget or reducing daily article count.', 'prautoblogger' ),
$utilization
)
)
__( 'PRAutoBlogger: Monthly API budget is at %.0f%%. Consider increasing the budget or reducing daily article count.', 'prautoblogger' ),
$utilization
) )
);
}
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Registers and renders the main PRAutoBlogger settings page in wp-admin.
*
* Uses the WordPress Settings API for option registration, sanitization, and rendering.
@@ -28,7 +26,7 @@ class PRAutoBlogger_Admin_Page {
__( 'PRAutoBlogger', 'prautoblogger' ),
'manage_options',
self::PAGE_SLUG,
array( $this, 'render_page' ),
[ $this, 'render_page' ],
'dashicons-edit-page',
30
);
@@ -45,62 +43,54 @@ class PRAutoBlogger_Admin_Page {
foreach ( $fields as $field ) {
$section = $field['section'] ?? 'prautoblogger_api';
register_setting(
self::OPTION_GROUP,
$field['id'],
array(
'type' => $field['wp_type'] ?? 'string',
'sanitize_callback' => array( $this, 'sanitize_field' ),
'default' => $field['default'] ?? '',
)
);
add_settings_field( $field['id'], $field['label'], array( $this, 'render_field' ), self::PAGE_SLUG, $section, $field );
register_setting( self::OPTION_GROUP, $field['id'], [
'type' => $field['wp_type'] ?? 'string',
'sanitize_callback' => [ $this, 'sanitize_field' ],
'default' => $field['default'] ?? '',
] );
add_settings_field( $field['id'], $field['label'], [ $this, 'render_field' ], self::PAGE_SLUG, $section, $field );
}
}
/** Enqueue admin CSS and JS on all PRAutoBlogger admin pages. */
public function on_enqueue_assets( string $hook_suffix ): void {
$pages = array(
$pages = [
'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 ) ) {
return;
}
wp_enqueue_style( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/admin.css', array(), PRAUTOBLOGGER_VERSION );
wp_enqueue_style( 'prautoblogger-model-picker', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/model-picker.css', array( 'prautoblogger-admin' ), PRAUTOBLOGGER_VERSION );
wp_enqueue_script( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/admin.js', array( 'jquery' ), PRAUTOBLOGGER_VERSION, true );
wp_enqueue_script( 'prautoblogger-model-picker', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/model-picker.js', array( 'jquery', 'prautoblogger-admin' ), PRAUTOBLOGGER_VERSION, true );
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',
array(
'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(),
)
);
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', array( 'jquery', 'prautoblogger-admin' ), PRAUTOBLOGGER_VERSION, true );
wp_enqueue_script( 'prautoblogger-ideas-browser', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/ideas-browser.js', [ 'jquery', 'prautoblogger-admin' ], PRAUTOBLOGGER_VERSION, true );
}
}
@@ -137,22 +127,15 @@ class PRAutoBlogger_Admin_Page {
case 'url':
case 'time':
$attrs = '';
if ( isset( $args['min'] ) ) {
$attrs .= ' min="' . esc_attr( (string) $args['min'] ) . '"'; }
if ( isset( $args['max'] ) ) {
$attrs .= ' max="' . esc_attr( (string) $args['max'] ) . '"'; }
if ( isset( $args['step'] ) ) {
$attrs .= ' step="' . esc_attr( (string) $args['step'] ) . '"'; }
if ( isset( $args['min'] ) ) { $attrs .= ' min="' . esc_attr( (string) $args['min'] ) . '"'; }
if ( isset( $args['max'] ) ) { $attrs .= ' max="' . esc_attr( (string) $args['max'] ) . '"'; }
if ( isset( $args['step'] ) ) { $attrs .= ' step="' . esc_attr( (string) $args['step'] ) . '"'; }
printf( '<input type="%s" id="%s" name="%s" value="%s" class="ab-input" %s />', esc_attr( $type ), $id, $id, esc_attr( (string) $value ), $attrs );
break;
case 'password':
printf(
'<input type="password" id="%s" name="%s" value="" class="ab-input" placeholder="%s" autocomplete="off" />',
$id,
$id,
'' !== $value ? esc_attr__( 'Saved (enter new value to change)', 'prautoblogger' ) : ''
);
printf( '<input type="password" id="%s" name="%s" value="" class="ab-input" placeholder="%s" autocomplete="off" />',
$id, $id, '' !== $value ? esc_attr__( 'Saved (enter new value to change)', 'prautoblogger' ) : '' );
break;
case 'textarea':
@@ -161,40 +144,30 @@ class PRAutoBlogger_Admin_Page {
case 'select':
printf( '<select id="%s" name="%s" class="ab-select">', $id, $id );
foreach ( ( $args['options'] ?? array() ) as $v => $label ) {
foreach ( ( $args['options'] ?? [] ) as $v => $label ) {
printf( '<option value="%s"%s>%s</option>', esc_attr( (string) $v ), selected( $value, (string) $v, false ), esc_html( $label ) );
}
echo '</select>';
break;
case 'toggle':
$checked = in_array( $value, array( '1', 'yes', true ), true );
printf(
'<label class="ab-toggle"><input type="hidden" name="%s" value="0" /><input type="checkbox" name="%s" value="1" %s /><span class="ab-toggle-slider"></span></label>',
$id,
$id,
checked( $checked, true, false )
);
$checked = in_array( $value, [ '1', 'yes', true ], true );
printf( '<label class="ab-toggle"><input type="hidden" name="%s" value="0" /><input type="checkbox" name="%s" value="1" %s /><span class="ab-toggle-slider"></span></label>',
$id, $id, checked( $checked, true, false ) );
break;
case 'checkboxes':
$current = json_decode( (string) $value, true ) ?? array();
$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'] ?? array() ) as $v => $label ) {
foreach ( ( $args['options'] ?? [] ) as $v => $label ) {
$is_checked = in_array( $v, $current, true );
$disabled = strpos( $label, 'coming soon' ) !== false ? ' disabled' : '';
printf(
'<label class="ab-checkbox-label"><input type="checkbox" name="%s[]" value="%s" %s%s /> %s</label>',
$id,
esc_attr( $v ),
checked( $is_checked, true, false ),
$disabled,
esc_html( $label )
);
printf( '<label class="ab-checkbox-label"><input type="checkbox" name="%s[]" value="%s" %s%s /> %s</label>',
$id, esc_attr( $v ), checked( $is_checked, true, false ), $disabled, esc_html( $label ) );
}
break;
@@ -207,30 +180,11 @@ class PRAutoBlogger_Admin_Page {
break;
case 'author_select':
wp_dropdown_users(
array(
'name' => $id,
'id' => $id,
'selected' => absint( $value ),
'show_option_none' => __( '— Auto (first admin) —', 'prautoblogger' ),
'option_none_value' => '0',
'class' => 'ab-select',
)
);
wp_dropdown_users( [ 'name' => $id, 'id' => $id, 'selected' => absint( $value ), 'show_option_none' => __( '— Auto (first admin) —', 'prautoblogger' ), 'option_none_value' => '0', 'class' => 'ab-select' ] );
break;
case 'category_select':
wp_dropdown_categories(
array(
'name' => $id,
'id' => $id,
'selected' => absint( $value ),
'show_option_none' => __( '— Auto-assign by type —', 'prautoblogger' ),
'option_none_value' => '0',
'class' => 'ab-select',
'hide_empty' => false,
)
);
wp_dropdown_categories( [ 'name' => $id, 'id' => $id, 'selected' => absint( $value ), 'show_option_none' => __( '— Auto-assign by type —', 'prautoblogger' ), 'option_none_value' => '0', 'class' => 'ab-select', 'hide_empty' => false ] );
break;
}
@@ -277,7 +231,7 @@ class PRAutoBlogger_Admin_Page {
$option_name = substr( $filter, strlen( 'sanitize_option_' ) );
}
$encrypted = array( 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' );
$encrypted = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_cloudflare_ai_token' ];
if ( in_array( $option_name, $encrypted, true ) ) {
// Empty value means password field wasn't touched — keep existing.
if ( '' === $value ) {
@@ -297,7 +251,7 @@ class PRAutoBlogger_Admin_Page {
return PRAutoBlogger_Encryption::encrypt( sanitize_text_field( $value ) );
}
$json_fields = array( 'prautoblogger_target_subreddits', 'prautoblogger_topic_exclusions', 'prautoblogger_enabled_sources' );
$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 ) ) {
@@ -321,7 +275,7 @@ class PRAutoBlogger_Admin_Page {
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $items ) ) );
}
$numeric = array( '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' );
$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;
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* WordPress Dashboard widget showing PRAutoBlogger generation status at a glance.
*
* Displays: last generation time, articles generated today, monthly cost,
@@ -29,7 +27,7 @@ class PRAutoBlogger_Dashboard_Widget {
wp_add_dashboard_widget(
'prautoblogger_status',
__( 'PRAutoBlogger Status', 'prautoblogger' ),
array( $this, 'render_widget' )
[ $this, 'render_widget' ]
);
}
@@ -46,8 +44,7 @@ class PRAutoBlogger_Dashboard_Widget {
$next_run = wp_next_scheduled( 'prautoblogger_daily_generation' );
printf(
'<p><strong>%s</strong> $%.2f / $%.2f (%.0f%%)</p>',
printf( '<p><strong>%s</strong> $%.2f / $%.2f (%.0f%%)</p>',
esc_html__( 'Monthly Spend:', 'prautoblogger' ),
$monthly_spend,
$budget,
@@ -55,8 +52,7 @@ class PRAutoBlogger_Dashboard_Widget {
);
if ( false !== $next_run ) {
printf(
'<p><strong>%s</strong> %s</p>',
printf( '<p><strong>%s</strong> %s</p>',
esc_html__( 'Next Generation:', 'prautoblogger' ),
esc_html( wp_date( 'M j, Y g:i A', $next_run ) )
);

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Admin page listing analysis results (article ideas) with per-idea generation.
*
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu`.
@@ -30,7 +28,7 @@ class PRAutoBlogger_Ideas_Browser {
__( 'Ideas', 'prautoblogger' ),
'manage_options',
'prautoblogger-ideas',
array( $this, 'render_page' )
[ $this, 'render_page' ]
);
}
@@ -64,51 +62,43 @@ class PRAutoBlogger_Ideas_Browser {
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( array( 'message' => 'Insufficient permissions.' ), 403 );
wp_send_json_error( [ 'message' => 'Insufficient permissions.' ], 403 );
return;
}
$idea_id = absint( $_POST['idea_id'] ?? 0 );
if ( $idea_id < 1 ) {
wp_send_json_error( array( 'message' => 'Invalid idea ID.' ) );
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( array( 'message' => 'Idea not found.' ) );
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,
array(
'status' => 'running',
'stage' => __( 'Starting generation…', 'prautoblogger' ),
'started' => time(),
),
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, array( $idea_id ) ) ) {
wp_schedule_single_event( time(), $hook, array( $idea_id ) );
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 ) ) ),
array(
'timeout' => 0.01,
'blocking' => false,
'sslverify' => false,
)
[ 'timeout' => 0.01, 'blocking' => false, 'sslverify' => false ]
);
wp_send_json_success( array( 'message' => 'Generation started.' ) );
wp_send_json_success( [ 'message' => 'Generation started.' ] );
}
/**
@@ -119,14 +109,14 @@ class PRAutoBlogger_Ideas_Browser {
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( array( 'message' => 'Insufficient permissions.' ), 403 );
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( array( 'status' => 'idle' ) );
wp_send_json_success( [ 'status' => 'idle' ] );
return;
}
@@ -152,26 +142,12 @@ class PRAutoBlogger_Ideas_Browser {
$key = self::STATUS_PREFIX . $idea_id;
$idea_data = get_transient( self::STATUS_PREFIX . 'data_' . $idea_id );
if ( ! is_array( $idea_data ) ) {
set_transient(
$key,
array(
'status' => 'error',
'message' => 'Idea data expired.',
),
self::STATUS_TTL
);
set_transient( $key, [ 'status' => 'error', 'message' => 'Idea data expired.' ], self::STATUS_TTL );
return;
}
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
set_transient(
$key,
array(
'status' => 'error',
'message' => 'Another generation is running.',
),
self::STATUS_TTL
);
set_transient( $key, [ 'status' => 'error', 'message' => 'Another generation is running.' ], self::STATUS_TTL );
return;
}
@@ -190,30 +166,19 @@ class PRAutoBlogger_Ideas_Browser {
// 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,
array(
'status' => 'complete',
'generated' => $result['generated'],
'published' => $result['published'],
'cost' => $result['cost'],
'post_id' => $post_id,
),
self::STATUS_TTL
);
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,
array(
'status' => 'error',
'message' => $e->getMessage(),
),
self::STATUS_TTL
);
set_transient( $key, [ 'status' => 'error', 'message' => $e->getMessage() ], self::STATUS_TTL );
}
PRAutoBlogger_Generation_Lock::release();
@@ -239,7 +204,7 @@ class PRAutoBlogger_Ideas_Browser {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$post_id = $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare(
"SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL LIMIT 1",
$run_id
)
@@ -258,7 +223,7 @@ class PRAutoBlogger_Ideas_Browser {
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$row = $wpdb->get_row( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$row = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $idea_id ),
ARRAY_A
);
@@ -266,26 +231,26 @@ class PRAutoBlogger_Ideas_Browser {
return null;
}
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?? array();
return array(
$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 ) ?? array(),
'key_points' => $meta['key_points'] ?? array(),
'target_keywords' => $meta['target_keywords'] ?? array(),
);
'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 = array();
$params = array();
$where = [];
$params = [];
if ( '' !== $type ) {
$where[] = 'analysis_type = %s';
@@ -296,10 +261,10 @@ class PRAutoBlogger_Ideas_Browser {
$offset = ( $paged - 1 ) * self::PER_PAGE;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
? "SELECT COUNT(*) FROM {$table} {$where_sql}" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
: $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$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';
@@ -307,14 +272,11 @@ class PRAutoBlogger_Ideas_Browser {
$full_sql = "SELECT * FROM {$table} {$where_sql} {$order_sql} {$limit_sql}";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
? $wpdb->get_results( $full_sql, ARRAY_A ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
: $wpdb->get_results( $wpdb->prepare( $full_sql, ...$params ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$rows = empty( $params )
? $wpdb->get_results( $full_sql, ARRAY_A )
: $wpdb->get_results( $wpdb->prepare( $full_sql, ...$params ), ARRAY_A );
return array(
'rows' => $rows ?? array(),
'total' => $total,
);
return [ 'rows' => $rows ?: [], 'total' => $total ];
}
/** Get counts per analysis_type for the filter badges. */
@@ -323,13 +285,13 @@ class PRAutoBlogger_Ideas_Browser {
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = $wpdb->get_results(
"SELECT analysis_type, COUNT(*) AS cnt FROM {$table} GROUP BY analysis_type ORDER BY cnt DESC",
ARRAY_A
);
$counts = array();
foreach ( $rows ?? array() as $row ) {
$counts = [];
foreach ( $rows ?: [] as $row ) {
$counts[ $row['analysis_type'] ] = (int) $row['cnt'];
}
return $counts;

View File

@@ -2,19 +2,16 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* 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).
* Who calls it: PRAutoBlogger_Admin_Page (model picker UI) and
* PRAutoBlogger_Image_Pipeline (model validation).
* 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.
* @see core/class-image-pipeline.php — Validates selected model against this list.
*/
class PRAutoBlogger_Image_Model_Registry {
@@ -24,75 +21,64 @@ class PRAutoBlogger_Image_Model_Registry {
* Each entry contains:
* - id: Model identifier used in API calls.
* - name: Human-readable display name.
* - provider: 'runware' | 'openrouter'.
* - provider: 'openrouter' or 'cloudflare'.
* - 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 array(
array(
'id' => 'runware:100@1',
'name' => 'FLUX.1 schnell (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0006,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Default. Fast, very cheap, comic-friendly fidelity.', 'prautoblogger' ),
),
array(
'id' => 'runware:101@1',
'name' => 'FLUX.1 dev (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.02,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Higher fidelity FLUX. ~30x schnell cost.', 'prautoblogger' ),
),
array(
return [
[
'id' => 'google/gemini-2.5-flash-image',
'name' => 'Gemini 2.5 Flash Image (Nano Banana)',
'provider' => 'openrouter',
'cost_per_image' => 0.005,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Mid-tier quality, proven.', 'prautoblogger' ),
),
array(
'capabilities' => [ 'image_generation' ],
'description' => __( 'Good quality, low cost. Recommended.', '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' => array( 'image_generation' ),
'capabilities' => [ 'image_generation' ],
'description' => __( 'Latest Google. Better quality.', 'prautoblogger' ),
),
array(
],
[
'id' => 'google/gemini-3-pro-image-preview',
'name' => 'Gemini 3 Pro Image (Nano Banana Pro)',
'provider' => 'openrouter',
'cost_per_image' => 0.03,
'capabilities' => array( 'image_generation' ),
'capabilities' => [ 'image_generation' ],
'description' => __( 'Highest quality Google. Premium.', 'prautoblogger' ),
),
array(
],
[
'id' => 'openai/gpt-5-image-mini',
'name' => 'GPT-5 Image Mini',
'provider' => 'openrouter',
'cost_per_image' => 0.02,
'capabilities' => array( 'image_generation' ),
'capabilities' => [ 'image_generation' ],
'description' => __( 'OpenAI budget image model.', 'prautoblogger' ),
),
array(
],
[
'id' => 'openai/gpt-5-image',
'name' => 'GPT-5 Image',
'provider' => 'openrouter',
'cost_per_image' => 0.08,
'capabilities' => array( 'image_generation' ),
'capabilities' => [ 'image_generation' ],
'description' => __( 'OpenAI premium. Photorealistic.', 'prautoblogger' ),
),
);
],
[
'id' => 'flux-1-schnell',
'name' => 'FLUX.1 schnell (Cloudflare)',
'provider' => 'cloudflare',
'cost_per_image' => 0.0007,
'capabilities' => [ 'image_generation' ],
'description' => __( 'Cheapest. Low quality, 4-step.', 'prautoblogger' ),
],
];
}
/**
@@ -100,7 +86,7 @@ class PRAutoBlogger_Image_Model_Registry {
* model id is not in the registry.
*
* @param string $model_id Model slug from the admin UI.
* @return string Provider id ('runware' | 'openrouter' | '').
* @return string Provider id ('openrouter' | 'cloudflare' | '').
*/
public static function provider_for( string $model_id ): string {
foreach ( self::get_models() as $model ) {

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Admin page displaying structured application log entries.
*
* Provides level filtering (error/warning/info/debug), text search,
@@ -31,7 +29,7 @@ class PRAutoBlogger_Log_Viewer {
__( 'Activity Log', 'prautoblogger' ),
'manage_options',
'prautoblogger-logs',
array( $this, 'render_page' )
[ $this, 'render_page' ]
);
}
@@ -68,21 +66,19 @@ class PRAutoBlogger_Log_Viewer {
check_ajax_referer( 'prautoblogger_clear_logs', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
return;
}
$days = isset( $_POST['days'] ) ? absint( $_POST['days'] ) : 30;
$deleted = PRAutoBlogger_Logger::prune( $days );
wp_send_json_success(
array(
'message' => sprintf(
wp_send_json_success( [
'message' => sprintf(
/* translators: %d: number of entries deleted */
__( '%d log entries cleared.', 'prautoblogger' ),
$deleted
),
)
);
__( '%d log entries cleared.', 'prautoblogger' ),
$deleted
),
] );
}
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Admin page displaying cost dashboard, content scores, and generation history.
*
* Shows: monthly spend, daily cost chart, spend by pipeline stage, budget
@@ -29,7 +27,7 @@ class PRAutoBlogger_Metrics_Page {
__( 'Metrics & Costs', 'prautoblogger' ),
'manage_options',
'prautoblogger-metrics',
array( $this, 'render_page' )
[ $this, 'render_page' ]
);
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Adds "Writing Model", "Image Model", and "Cost" columns to the Posts list.
*
* Shows which LLM wrote each article, which model generated its featured image,
@@ -25,11 +23,11 @@ class PRAutoBlogger_Post_List_Columns {
* Side effects: adds WordPress filters/actions for post list customization.
*/
public function register(): void {
add_filter( 'manage_posts_columns', array( $this, 'filter_add_columns' ) );
add_action( 'manage_posts_custom_column', array( $this, 'on_render_column' ), 10, 2 );
add_filter( 'manage_edit-post_sortable_columns', array( $this, 'filter_sortable_columns' ) );
add_action( 'pre_get_posts', array( $this, 'on_sort_by_model' ) );
add_action( 'admin_head', array( $this, 'on_column_styles' ) );
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' ] );
}
/**
@@ -39,7 +37,7 @@ class PRAutoBlogger_Post_List_Columns {
* @return array<string, string> Modified columns.
*/
public function filter_add_columns( array $columns ): array {
$new = array();
$new = [];
foreach ( $columns as $key => $label ) {
$new[ $key ] = $label;
if ( 'title' === $key ) {
@@ -182,7 +180,7 @@ class PRAutoBlogger_Post_List_Columns {
// 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 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT stage, model, prompt_tokens, completion_tokens, estimated_cost
FROM {$table} WHERE post_id = %d ORDER BY id ASC",
$post_id
),
@@ -194,7 +192,7 @@ class PRAutoBlogger_Post_List_Columns {
return;
}
$total = array_sum( array_column( $rows, 'estimated_cost' ) );
$total = array_sum( array_column( $rows, 'estimated_cost' ) );
$formatted = $total < 0.01
? sprintf( '$%.4f', $total )
: sprintf( '$%.2f', $total );
@@ -245,7 +243,7 @@ class PRAutoBlogger_Post_List_Columns {
. '<td><strong>%s</strong></td></tr>',
esc_html( $total_f )
);
$html .= '</table>';
$html .= '</table>';
return $html;
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Adds a metabox to posts showing PRAutoBlogger generation metadata.
*
* Only appears on posts that were generated by PRAutoBlogger (checked via
@@ -36,7 +34,7 @@ class PRAutoBlogger_Post_Metabox {
add_meta_box(
'prautoblogger_generation_info',
__( 'PRAutoBlogger Generation Info', 'prautoblogger' ),
array( $this, 'render_metabox' ),
[ $this, 'render_metabox' ],
'post',
'side',
'default'

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Admin page listing generated draft posts for editorial review.
*
* Provides approve (publish), edit (opens WP editor), and reject (trash)
@@ -30,11 +28,11 @@ class PRAutoBlogger_Review_Queue {
__( 'Review Queue', 'prautoblogger' ),
'manage_options',
'prautoblogger-review-queue',
array( $this, 'render_page' )
[ $this, 'render_page' ]
);
if ( false !== $hook ) {
add_action( "load-{$hook}", array( $this, 'on_handle_bulk_actions' ) );
add_action( "load-{$hook}", [ $this, 'on_handle_bulk_actions' ] );
}
}
@@ -62,22 +60,20 @@ class PRAutoBlogger_Review_Queue {
* @return \WP_Query
*/
public function get_pending_posts( int $paged = 1 ): \WP_Query {
return new \WP_Query(
array(
'post_type' => 'post',
'post_status' => 'draft',
'posts_per_page' => 20,
'paged' => $paged,
'meta_query' => array(
array(
'key' => '_prautoblogger_generated',
'value' => '1',
),
),
'orderby' => 'date',
'order' => 'DESC',
)
);
return new \WP_Query( [
'post_type' => 'post',
'post_status' => 'draft',
'posts_per_page' => 20,
'paged' => $paged,
'meta_query' => [
[
'key' => '_prautoblogger_generated',
'value' => '1',
],
],
'orderby' => 'date',
'order' => 'DESC',
] );
}
/**
@@ -104,7 +100,7 @@ class PRAutoBlogger_Review_Queue {
$action = sanitize_text_field( wp_unslash( $_POST['prautoblogger_bulk_action'] ) );
$post_ids = isset( $_POST['prautoblogger_post_ids'] ) && is_array( $_POST['prautoblogger_post_ids'] )
? array_map( 'absint', $_POST['prautoblogger_post_ids'] )
: array();
: [];
if ( empty( $post_ids ) ) {
return;
@@ -117,31 +113,25 @@ class PRAutoBlogger_Review_Queue {
}
if ( 'approve' === $action ) {
$result = wp_update_post(
array(
'ID' => $post_id,
'post_status' => 'publish',
),
true
);
$result = wp_update_post( [ 'ID' => $post_id, 'post_status' => 'publish' ], true );
if ( ! is_wp_error( $result ) ) {
update_post_meta( $post_id, '_prautoblogger_approved_at', gmdate( 'c' ) );
++$count;
$count++;
}
} elseif ( 'reject' === $action ) {
$result = wp_trash_post( $post_id );
if ( false !== $result ) {
++$count;
$count++;
}
}
}
$redirect = add_query_arg(
array(
'page' => 'prautoblogger-review-queue',
[
'page' => 'prautoblogger-review-queue',
'prautoblogger_bulk_done' => $count,
'prautoblogger_bulk_type' => $action,
),
],
admin_url( 'admin.php' )
);
@@ -160,36 +150,25 @@ class PRAutoBlogger_Review_Queue {
check_ajax_referer( 'prautoblogger_review_queue', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
return;
}
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
if ( $post_id <= 0 ) {
wp_send_json_error( array( 'message' => __( 'Invalid post ID.', 'prautoblogger' ) ) );
wp_send_json_error( [ 'message' => __( 'Invalid post ID.', 'prautoblogger' ) ] );
return;
}
$result = wp_update_post(
array(
'ID' => $post_id,
'post_status' => 'publish',
),
true
);
$result = wp_update_post( [ 'ID' => $post_id, 'post_status' => 'publish' ], true );
if ( is_wp_error( $result ) ) {
wp_send_json_error( array( 'message' => $result->get_error_message() ) );
wp_send_json_error( [ 'message' => $result->get_error_message() ] );
return;
}
update_post_meta( $post_id, '_prautoblogger_approved_at', gmdate( 'c' ) );
wp_send_json_success(
array(
'message' => __( 'Post published.', 'prautoblogger' ),
'post_id' => $post_id,
)
);
wp_send_json_success( [ 'message' => __( 'Post published.', 'prautoblogger' ), 'post_id' => $post_id ] );
}
/**
@@ -203,27 +182,22 @@ class PRAutoBlogger_Review_Queue {
check_ajax_referer( 'prautoblogger_review_queue', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
return;
}
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
if ( $post_id <= 0 ) {
wp_send_json_error( array( 'message' => __( 'Invalid post ID.', 'prautoblogger' ) ) );
wp_send_json_error( [ 'message' => __( 'Invalid post ID.', 'prautoblogger' ) ] );
return;
}
$result = wp_trash_post( $post_id );
if ( false === $result || null === $result ) {
wp_send_json_error( array( 'message' => __( 'Failed to reject post.', 'prautoblogger' ) ) );
wp_send_json_error( [ 'message' => __( 'Failed to reject post.', 'prautoblogger' ) ] );
return;
}
wp_send_json_success(
array(
'message' => __( 'Post rejected.', 'prautoblogger' ),
'post_id' => $post_id,
)
);
wp_send_json_success( [ 'message' => __( 'Post rejected.', 'prautoblogger' ), 'post_id' => $post_id ] );
}
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Extended settings fields: schedule, publishing, analytics, display, and images.
*
* What: Declarative field definitions for operational settings sections.
@@ -35,9 +33,9 @@ class PRAutoBlogger_Settings_Fields_Extended {
* @return array<int, array<string, mixed>>
*/
public static function get_fields(): array {
return array(
return [
// ── Sources (extended) ──────────────────────────────────────
array(
[
'id' => 'prautoblogger_research_model',
'label' => __( 'Research Model', 'prautoblogger' ),
'type' => 'model_select',
@@ -46,43 +44,43 @@ class PRAutoBlogger_Settings_Fields_Extended {
'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' ),
),
array(
],
[
'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) ────────────────────────────────────
array(
[
'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' ),
),
array(
'id' => 'prautoblogger_reasoning_effort',
'label' => __( 'Reasoning Effort', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_models',
'default' => 'medium',
'options' => array(
],
[
'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 ───────────────────────────────────────
array(
[
'id' => 'prautoblogger_daily_article_target',
'label' => __( 'Articles per Day', 'prautoblogger' ),
'type' => 'number',
@@ -90,20 +88,16 @@ class PRAutoBlogger_Settings_Fields_Extended {
'default' => 1,
'min' => 1,
'max' => 10,
),
array(
],
[
'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' )
),
),
array(
'description' => __( 'Daily generation runs at this time (server timezone).', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_monthly_budget_usd',
'label' => __( 'Monthly Budget (USD)', 'prautoblogger' ),
'type' => 'number',
@@ -112,66 +106,66 @@ class PRAutoBlogger_Settings_Fields_Extended {
'min' => 0,
'step' => '0.01',
'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ),
),
],
// ── Publishing ──────────────────────────────────────────────
array(
[
'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' ),
),
array(
],
[
'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' ),
),
array(
],
[
'id' => 'prautoblogger_default_category',
'label' => __( 'Default Category', 'prautoblogger' ),
'type' => 'category_select',
'section' => 'prautoblogger_publishing',
'default' => 0,
'description' => __( 'Fallback category for generated posts.', 'prautoblogger' ),
),
array(
],
[
'id' => 'prautoblogger_log_level',
'label' => __( 'Log Level', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_publishing',
'default' => 'info',
'options' => array(
'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 ───────────────────────────────────────────────
array(
[
'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' ),
),
array(
'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' ),
),
],
// ── Display ────────────────────────────────────────────────
array(
[
'id' => 'prautoblogger_article_font_family',
'label' => __( 'Article Font', 'prautoblogger' ),
'type' => 'select',
@@ -179,8 +173,8 @@ class PRAutoBlogger_Settings_Fields_Extended {
'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' ),
),
array(
],
[
'id' => 'prautoblogger_article_font_size',
'label' => __( 'Article Font Size (px)', 'prautoblogger' ),
'type' => 'number',
@@ -189,75 +183,83 @@ class PRAutoBlogger_Settings_Fields_Extended {
'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' ),
),
array(
],
[
'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 ─────────────────────────────────────────────────
array(
[
'id' => 'prautoblogger_image_enabled',
'label' => __( 'Enable Image Generation', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '0',
'description' => __( 'Generate images for each published article.', 'prautoblogger' ),
),
array(
],
[
'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' ),
),
array(
],
[
'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' ),
'description' => __( 'Pick an image model. The provider (OpenRouter or Cloudflare Workers AI) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ),
'badge' => __( 'Quality', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_runware_api_key',
'label' => __( 'Runware API Key', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_cloudflare_ai_token',
'label' => __( 'Cloudflare API Token', 'prautoblogger' ),
'type' => 'password',
'section' => 'prautoblogger_images',
'description' => __( 'Required when using a Runware FLUX model. Get your key at runware.ai/signup.', 'prautoblogger' ),
'description' => __( 'Only needed if using Cloudflare provider. Workers AI token with Read + Edit scope.', 'prautoblogger' ),
'icon' => '🔑',
),
array(
],
[
'id' => 'prautoblogger_cloudflare_account_id',
'label' => __( 'Cloudflare Account ID', 'prautoblogger' ),
'type' => 'text',
'section' => 'prautoblogger_images',
'default' => '',
'description' => __( 'Only needed if using Cloudflare provider.', '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 visual look. Changing mid-run causes visible style drift.', 'prautoblogger' ),
),
array(
],
[
'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' ),
),
array(
],
[
'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' ),
),
);
'description' => __( 'When the provider rejects an image prompt as NSFW (e.g. Cloudflare code 3030), 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

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Declarative settings fields and sections for the PRAutoBlogger admin page.
*
* Centralizes all field and section definitions in one place, making it trivial
@@ -26,53 +24,53 @@ class PRAutoBlogger_Settings_Fields {
* @return array<string, array{title: string, icon: string, description: string}>
*/
public static function get_sections(): array {
return array(
'prautoblogger_api' => array(
return [
'prautoblogger_api' => [
'title' => __( 'API Keys', 'prautoblogger' ),
'icon' => 'dashicons-admin-network',
'description' => __( 'Connect your external services. Keys are encrypted at rest.', 'prautoblogger' ),
),
'prautoblogger_models' => array(
],
'prautoblogger_models' => [
'title' => __( 'AI Models', 'prautoblogger' ),
'icon' => 'dashicons-superhero-alt',
'description' => __( 'Choose which models power each stage of the pipeline.', 'prautoblogger' ),
),
'prautoblogger_content' => array(
],
'prautoblogger_content' => [
'title' => __( 'Content', 'prautoblogger' ),
'icon' => 'dashicons-edit-large',
'description' => __( 'Control tone, length, pipeline mode, and topic guardrails.', 'prautoblogger' ),
),
'prautoblogger_sources' => array(
],
'prautoblogger_sources' => [
'title' => __( 'Sources', 'prautoblogger' ),
'icon' => 'dashicons-rss',
'description' => __( 'Configure where PRAutoBlogger finds trending topics.', 'prautoblogger' ),
),
'prautoblogger_schedule' => array(
],
'prautoblogger_schedule' => [
'title' => __( 'Schedule & Budget', 'prautoblogger' ),
'icon' => 'dashicons-calendar-alt',
'description' => __( 'Set daily generation schedule, volume, and spending limits.', 'prautoblogger' ),
),
'prautoblogger_publishing' => array(
],
'prautoblogger_publishing' => [
'title' => __( 'Publishing', 'prautoblogger' ),
'icon' => 'dashicons-megaphone',
'description' => __( 'Control how generated content is published.', 'prautoblogger' ),
),
'prautoblogger_analytics' => array(
],
'prautoblogger_analytics' => [
'title' => __( 'Analytics', 'prautoblogger' ),
'icon' => 'dashicons-chart-area',
'description' => __( 'Connect Google Analytics 4 for post performance scoring.', 'prautoblogger' ),
),
'prautoblogger_display' => array(
],
'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' => array(
],
'prautoblogger_images' => [
'title' => __( 'Images', 'prautoblogger' ),
'icon' => 'dashicons-format-image',
'description' => __( 'Image provider, model, and style controls. OpenRouter reuses your existing API key.', 'prautoblogger' ),
),
);
],
];
}
/**
@@ -90,25 +88,25 @@ class PRAutoBlogger_Settings_Fields {
* @return array<int, array<string, mixed>>
*/
private static function get_core_fields(): array {
return array(
return [
// ── API Keys ────────────────────────────────────────────────
array(
[
'id' => 'prautoblogger_openrouter_api_key',
'label' => __( 'OpenRouter API Key', 'prautoblogger' ),
'type' => 'password',
'section' => 'prautoblogger_api',
'description' => __( 'Get your key at openrouter.ai/keys', 'prautoblogger' ),
'icon' => '🔑',
),
array(
],
[
'id' => 'prautoblogger_reddit_source_status',
'label' => __( 'Reddit Data Source', 'prautoblogger' ),
'type' => 'source_status',
'section' => 'prautoblogger_api',
'description' => __( 'Uses Reddit RSS (primary) and .json (fallback). No API key required.', 'prautoblogger' ),
'icon' => '📡',
),
array(
],
[
'id' => 'prautoblogger_ai_gateway_base_url',
'label' => __( 'Cloudflare AI Gateway URL', 'prautoblogger' ),
'type' => 'text',
@@ -116,8 +114,8 @@ class PRAutoBlogger_Settings_Fields {
'default' => '',
'description' => __( 'Optional. Route OpenRouter calls through a Cloudflare AI Gateway for caching, cost logging, and rate limiting. Format: https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openrouter — leave blank to call OpenRouter directly.', 'prautoblogger' ),
'icon' => '☁️',
),
array(
],
[
'id' => 'prautoblogger_ai_gateway_cache_ttl',
'label' => __( 'AI Gateway Cache TTL (seconds)', 'prautoblogger' ),
'type' => 'number',
@@ -126,10 +124,10 @@ class PRAutoBlogger_Settings_Fields {
'min' => 0,
'max' => 2592000,
'description' => __( 'How long Cloudflare may serve cached responses for identical LLM calls. 0 disables caching. Only used when a gateway URL is set above. Safe values: 0 for article generation (always fresh), 3600+ for repeated classification/scoring calls.', 'prautoblogger' ),
),
],
// ── AI Models ───────────────────────────────────────────────
array(
[
'id' => 'prautoblogger_analysis_model',
'label' => __( 'Analysis Model', 'prautoblogger' ),
'type' => 'model_select',
@@ -138,8 +136,8 @@ class PRAutoBlogger_Settings_Fields {
'capability' => 'text→text',
'description' => __( 'Used for source analysis. Pick a cheap, fast model.', 'prautoblogger' ),
'badge' => __( 'Low cost', 'prautoblogger' ),
),
array(
],
[
'id' => 'prautoblogger_writing_model',
'label' => __( 'Writing Model', 'prautoblogger' ),
'type' => 'model_select',
@@ -148,8 +146,8 @@ class PRAutoBlogger_Settings_Fields {
'capability' => 'text→text',
'description' => __( 'Used for article generation. Quality matters here.', 'prautoblogger' ),
'badge' => __( 'Quality', 'prautoblogger' ),
),
array(
],
[
'id' => 'prautoblogger_editor_model',
'label' => __( 'Editor Model', 'prautoblogger' ),
'type' => 'model_select',
@@ -158,110 +156,110 @@ class PRAutoBlogger_Settings_Fields {
'capability' => 'text→text',
'description' => __( 'Used for the chief editor review pass.', 'prautoblogger' ),
'badge' => __( 'Quality', 'prautoblogger' ),
),
],
// ── Content ─────────────────────────────────────────────────
array(
[
'id' => 'prautoblogger_niche_description',
'label' => __( 'Niche Description', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_content',
'description' => __( 'Describe your site\'s niche. Guides content analysis and generation.', 'prautoblogger' ),
),
array(
],
[
'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' ),
),
array(
],
[
'id' => 'prautoblogger_tone',
'label' => __( 'Content Tone', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_content',
'default' => 'informational',
'options' => array(
'options' => [
'informational' => __( 'Informational', 'prautoblogger' ),
'conversational' => __( 'Conversational', 'prautoblogger' ),
'professional' => __( 'Professional', 'prautoblogger' ),
'casual' => __( 'Casual', 'prautoblogger' ),
'authoritative' => __( 'Authoritative', 'prautoblogger' ),
),
),
array(
],
],
[
'id' => 'prautoblogger_writing_pipeline',
'label' => __( 'Writing Pipeline', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_content',
'default' => 'multi_step',
'options' => array(
'options' => [
'multi_step' => __( 'Multi-step (outline → draft → polish)', 'prautoblogger' ),
'single_pass' => __( 'Single-pass (one LLM call)', 'prautoblogger' ),
),
),
array(
],
],
[
'id' => 'prautoblogger_min_word_count',
'label' => __( 'Min Word Count', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_content',
'default' => 800,
'min' => 200,
),
array(
],
[
'id' => 'prautoblogger_max_word_count',
'label' => __( 'Max Word Count', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_content',
'default' => 2000,
'min' => 500,
),
array(
],
[
'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' ),
),
array(
],
[
'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' ),
),
array(
],
[
'id' => 'prautoblogger_topic_exclusions',
'label' => __( 'Topic Exclusions', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_content',
'description' => __( 'Comma-separated topics to never write about.', 'prautoblogger' ),
),
],
// ── Sources ─────────────────────────────────────────────────
array(
[
'id' => 'prautoblogger_target_subreddits',
'label' => __( 'Target Subreddits', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_sources',
'description' => __( 'Comma-separated, without r/. E.g.: peptides, Nootropics, biohackers', 'prautoblogger' ),
),
array(
],
[
'id' => 'prautoblogger_enabled_sources',
'label' => __( 'Enabled Sources', 'prautoblogger' ),
'type' => 'checkboxes',
'section' => 'prautoblogger_sources',
'options' => array(
'options' => [
'reddit' => __( 'Reddit (RSS + .json)', 'prautoblogger' ),
'llm_research' => __( 'LLM Deep Research (reasoning model)', 'prautoblogger' ),
),
],
'default' => '["reddit"]',
'description' => __( 'Select which platforms to monitor for topics.', 'prautoblogger' ),
),
array(
],
[
'id' => 'prautoblogger_pullpush_cache_ttl',
'label' => __( 'Research Cache (hours)', 'prautoblogger' ),
'type' => 'number',
@@ -270,21 +268,21 @@ class PRAutoBlogger_Settings_Fields {
'min' => 1,
'max' => 72,
'description' => __( 'How long to cache Reddit research results before re-fetching. Saves bandwidth on low-traffic sites.', 'prautoblogger' ),
),
array(
],
[
'id' => 'prautoblogger_reddit_time_filter',
'label' => __( 'Reddit Time Window', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_sources',
'default' => 'day',
'options' => array(
'options' => [
'day' => __( 'Past 24 hours', 'prautoblogger' ),
'week' => __( 'Past week', 'prautoblogger' ),
'month' => __( 'Past month', 'prautoblogger' ),
),
],
'description' => __( 'How far back to search for trending posts and comments.', 'prautoblogger' ),
),
array(
],
[
'id' => 'prautoblogger_reddit_posts_per_subreddit',
'label' => __( 'Posts per Subreddit', 'prautoblogger' ),
'type' => 'number',
@@ -293,7 +291,7 @@ class PRAutoBlogger_Settings_Fields {
'min' => 5,
'max' => 100,
'description' => __( 'Maximum posts to fetch per subreddit per collection run.', 'prautoblogger' ),
),
);
],
];
}
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Renders the interactive model-picker field for OpenRouter models.
*
* What: a button showing the current model's name + pricing. Clicking it
@@ -27,8 +25,9 @@ class PRAutoBlogger_OpenRouter_Model_Field {
*/
public static function render( string $id, string $value, array $args ): void {
$capability = $args['capability'] ?? 'text→text';
$display_name = '' !== $value ? $value : __( '— Select a model —', 'prautoblogger' );
$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();

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Renders the Reddit source status indicator in the admin settings.
*
* What: shows RSS (primary) and .json (fallback) availability with

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Handles plugin activation: creates database tables, sets default options,
* and schedules the initial cron job.
*
@@ -30,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 );
}
/**
@@ -51,27 +155,26 @@ class PRAutoBlogger_Activator {
* @return void
*/
private static function set_default_options(): void {
$defaults = array(
$defaults = [
'prautoblogger_analysis_model' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
'prautoblogger_writing_model' => PRAUTOBLOGGER_DEFAULT_WRITING_MODEL,
'prautoblogger_editor_model' => PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL,
'prautoblogger_daily_article_target' => 1,
'prautoblogger_writing_pipeline' => 'multi_step',
'prautoblogger_niche_description' => '',
'prautoblogger_target_subreddits' => '[]',
'prautoblogger_monthly_budget_usd' => 50.00,
'prautoblogger_tone' => 'informational',
'prautoblogger_min_word_count' => 800,
'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',
);
'prautoblogger_daily_article_target' => 1,
'prautoblogger_writing_pipeline' => 'multi_step',
'prautoblogger_niche_description' => '',
'prautoblogger_target_subreddits' => '[]',
'prautoblogger_monthly_budget_usd' => 50.00,
'prautoblogger_tone' => 'informational',
'prautoblogger_min_word_count' => 800,
'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',
];
foreach ( $defaults as $key => $value ) {
add_option( $key, $value );
@@ -92,8 +195,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' );
}
}
@@ -103,172 +212,18 @@ class PRAutoBlogger_Activator {
// in class-prautoblogger.php may not be hooked yet.
$schedules = wp_get_schedules();
if ( ! isset( $schedules['prautoblogger_six_hours'] ) ) {
add_filter(
'cron_schedules',
static function ( array $scheds ): array {
$scheds['prautoblogger_six_hours'] = array(
'interval' => 6 * HOUR_IN_SECONDS,
'display' => __( 'Every Six Hours', 'prautoblogger' ),
);
return $scheds;
}
);
add_filter( 'cron_schedules', static function ( array $scheds ): array {
$scheds['prautoblogger_six_hours'] = [
'interval' => 6 * HOUR_IN_SECONDS,
'display' => __( 'Every Six Hours', 'prautoblogger' ),
];
return $scheds;
} );
}
// Schedule a separate metrics collection job (runs every 6 hours).
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,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$pairs = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"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, array( '', '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

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* AJAX endpoint handlers for PRAutoBlogger admin actions.
*
* What: Handles image generation, model registry, and connection test AJAX requests.
@@ -40,39 +38,39 @@ class PRAutoBlogger_Ajax_Handlers {
check_ajax_referer( 'prautoblogger_generate_image', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
return;
}
// Image generation can take 20-30s (Runware FLUX + OpenRouter image models).
// Image generation can take 20-30s on Cloudflare Workers AI.
// 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( array( 'message' => __( 'Missing post_id parameter.', 'prautoblogger' ) ) );
wp_send_json_error( [ 'message' => __( 'Missing post_id parameter.', 'prautoblogger' ) ] );
return;
}
$post = get_post( $post_id );
if ( ! $post ) {
wp_send_json_error( array( 'message' => sprintf( __( 'Post %d not found.', 'prautoblogger' ), $post_id ) ) );
wp_send_json_error( [ 'message' => sprintf( __( 'Post %d not found.', 'prautoblogger' ), $post_id ) ] );
return;
}
try {
$pipeline = new PRAutoBlogger_Image_Pipeline();
$article_data = array(
$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 ) : array();
$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 ) : array()
is_array( $source_ids ) ? array_map( 'absint', $source_ids ) : []
);
// The pipeline sets featured image and Image B meta internally.
@@ -84,7 +82,7 @@ class PRAutoBlogger_Ajax_Handlers {
sprintf( 'Retroactive image gen %s for post %d: %s', get_class( $e ), $post_id, $e->getMessage() ),
'image_pipeline'
);
wp_send_json_error( array( 'message' => $e->getMessage() ) );
wp_send_json_error( [ 'message' => $e->getMessage() ] );
}
}
@@ -100,7 +98,7 @@ class PRAutoBlogger_Ajax_Handlers {
check_ajax_referer( 'prautoblogger_get_models', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
return;
}
@@ -125,28 +123,22 @@ class PRAutoBlogger_Ajax_Handlers {
public function on_ajax_test_connection(): void {
check_ajax_referer( 'prautoblogger_test_connection', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
return;
}
$service = isset( $_POST['service'] ) ? sanitize_text_field( wp_unslash( $_POST['service'] ) ) : '';
$results = array();
$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();
$reddit = new PRAutoBlogger_Reddit_Provider();
$is_ok = $reddit->validate_credentials();
$results['reddit'] = $is_ok
? array(
'status' => 'ok',
'message' => __( 'Reddit source available (RSS primary, .json fallback).', 'prautoblogger' ),
)
: array(
'status' => 'error',
'message' => __( 'Reddit sources unreachable (both RSS and .json failed).', 'prautoblogger' ),
);
? [ '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

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* PSR-4-inspired autoloader for PRAutoBlogger classes.
*
* Maps class names like `PRAutoBlogger_Content_Generator` to file paths like
@@ -21,7 +19,7 @@ class PRAutoBlogger_Autoloader {
*
* @var string[]
*/
private static array $directories = array(
private static array $directories = [
'', // includes/ root
'admin/',
'admin/fields/',
@@ -30,7 +28,7 @@ class PRAutoBlogger_Autoloader {
'models/',
'frontend/',
'services/',
);
];
/**
* Register the autoloader with SPL.
@@ -38,7 +36,7 @@ class PRAutoBlogger_Autoloader {
* @return void
*/
public static function register(): void {
spl_autoload_register( array( __CLASS__, 'autoload' ) );
spl_autoload_register( [ __CLASS__, 'autoload' ] );
}
/**

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Handles plugin deactivation: clears scheduled cron events and transients.
*
* Note: does NOT delete data or options — that happens in uninstall.php.
@@ -35,11 +33,10 @@ class PRAutoBlogger_Deactivator {
* @return void
*/
private static function clear_cron(): void {
$hooks = array(
$hooks = [
'prautoblogger_daily_generation',
'prautoblogger_collect_metrics',
'prautoblogger_reap_orphan_research_rows',
);
];
foreach ( $hooks as $hook ) {
$timestamp = wp_next_scheduled( $hook );
@@ -57,11 +54,11 @@ class PRAutoBlogger_Deactivator {
* @return void
*/
private static function clear_transients(): void {
$transients = array(
$transients = [
'prautoblogger_reddit_token',
'prautoblogger_generation_lock',
'prautoblogger_openrouter_models',
);
];
foreach ( $transients as $transient ) {
delete_transient( $transient );

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Encrypts and decrypts sensitive option values (API keys, secrets).
*
* Uses OpenSSL AES-256-CBC with a key derived from wp_salt('auth').

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Cron/AJAX handlers for content generation, metrics collection, model registry.
*
* Triggered by: PRAutoBlogger hook registration.
@@ -85,7 +83,7 @@ class PRAutoBlogger_Executor {
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
return;
}
@@ -98,26 +96,20 @@ class PRAutoBlogger_Executor {
// 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(
array(
'background' => true,
'message' => __( 'Generation already in progress.', 'prautoblogger' ),
)
);
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,
array(
'status' => 'running',
'stage' => __( 'Starting generation...', 'prautoblogger' ),
'started' => time(),
'last_updated' => time(),
),
self::STATUS_TTL
);
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
@@ -134,19 +126,17 @@ class PRAutoBlogger_Executor {
// 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 ) ) ),
array(
[
'timeout' => 0.01,
'blocking' => false,
'sslverify' => false,
)
]
);
wp_send_json_success(
array(
'background' => true,
'message' => __( 'Generation started in background. Polling for status...', 'prautoblogger' ),
)
);
wp_send_json_success( [
'background' => true,
'message' => __( 'Generation started in background. Polling for status...', 'prautoblogger' ),
] );
}
/**
@@ -163,14 +153,10 @@ class PRAutoBlogger_Executor {
@set_time_limit( 300 );
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
set_transient(
self::STATUS_TRANSIENT,
array(
'status' => 'error',
'message' => __( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' ),
),
self::STATUS_TTL
);
set_transient( self::STATUS_TRANSIENT, [
'status' => 'error',
'message' => __( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' ),
], self::STATUS_TTL );
return;
}
@@ -183,17 +169,13 @@ class PRAutoBlogger_Executor {
// 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,
array(
'status' => 'complete',
'generated' => $results['generated'],
'published' => $results['published'],
'rejected' => $results['rejected'],
'cost' => $results['cost'],
),
self::STATUS_TTL
);
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 ) {
@@ -201,14 +183,10 @@ class PRAutoBlogger_Executor {
sprintf( 'Manual generation %s: %s', get_class( $e ), $e->getMessage() ),
'pipeline'
);
set_transient(
self::STATUS_TRANSIENT,
array(
'status' => 'error',
'message' => $e->getMessage(),
),
self::STATUS_TTL
);
set_transient( self::STATUS_TRANSIENT, [
'status' => 'error',
'message' => $e->getMessage(),
], self::STATUS_TTL );
PRAutoBlogger_Generation_Lock::release();
}
}
@@ -230,14 +208,10 @@ class PRAutoBlogger_Executor {
// Clean up on catastrophic failure.
delete_option( 'prautoblogger_article_queue' );
PRAutoBlogger_Generation_Lock::release();
set_transient(
self::STATUS_TRANSIENT,
array(
'status' => 'error',
'message' => $e->getMessage(),
),
self::STATUS_TTL
);
set_transient( self::STATUS_TRANSIENT, [
'status' => 'error',
'message' => $e->getMessage(),
], self::STATUS_TTL );
}
}
@@ -245,13 +219,13 @@ class PRAutoBlogger_Executor {
public function on_ajax_generation_status(): void {
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 );
return;
}
$status = get_transient( self::STATUS_TRANSIENT );
if ( ! is_array( $status ) ) {
wp_send_json_success( array( 'status' => 'idle' ) );
wp_send_json_success( [ 'status' => 'idle' ] );
return;
}
@@ -289,12 +263,7 @@ class PRAutoBlogger_Executor {
delete_transient( self::STATUS_TRANSIENT );
delete_option( 'prautoblogger_article_queue' );
PRAutoBlogger_Generation_Lock::release();
wp_send_json_success(
array(
'status' => 'error',
'message' => $message,
)
);
wp_send_json_success( [ 'status' => 'error', 'message' => $message ] );
}
/**

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Database-level atomic mutex for content generation.
*
* What: Provides acquire/release/check semantics for a single-writer generation lock.

View File

@@ -1,101 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* 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

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Main orchestrator for the PRAutoBlogger plugin.
*
* Registers all WordPress hooks (actions and filters) and wires up dependencies.
@@ -41,12 +39,12 @@ class PRAutoBlogger {
if ( $this->initialized ) {
return;
}
$this->initialized = true;
$this->executor = new PRAutoBlogger_Executor();
$this->initialized = true;
$this->executor = new PRAutoBlogger_Executor();
$this->ajax_handlers = new PRAutoBlogger_Ajax_Handlers( $this->executor->get_model_registry() );
add_action( 'admin_init', array( $this, 'on_check_db_version' ) );
add_filter( 'cron_schedules', array( $this, 'filter_add_cron_schedules' ) );
add_action( 'admin_init', [ $this, 'on_check_db_version' ] );
add_filter( 'cron_schedules', [ $this, 'filter_add_cron_schedules' ] );
if ( is_admin() ) {
$this->register_admin_hooks();
@@ -63,106 +61,83 @@ class PRAutoBlogger {
/** Register admin-only hooks (settings, notices, metabox, dashboard widget). */
private function register_admin_hooks(): void {
$admin_page = new PRAutoBlogger_Admin_Page();
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ) );
add_action( 'admin_init', array( $admin_page, 'on_register_settings' ) );
add_action( 'admin_enqueue_scripts', array( $admin_page, 'on_enqueue_assets' ) );
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', array( new PRAutoBlogger_Admin_Notices(), 'on_display_notices' ) );
add_action( 'add_meta_boxes', array( new PRAutoBlogger_Post_Metabox(), 'on_register_metabox' ) );
add_action( 'admin_menu', array( new PRAutoBlogger_Metrics_Page(), 'on_register_menu' ) );
add_action( 'wp_dashboard_setup', array( new PRAutoBlogger_Dashboard_Widget(), 'on_register_widget' ) );
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' ] );
$review_queue = new PRAutoBlogger_Review_Queue();
add_action( 'admin_menu', array( $review_queue, 'on_register_menu' ) );
add_action( 'admin_menu', [ $review_queue, 'on_register_menu' ] );
$ideas_browser = new PRAutoBlogger_Ideas_Browser();
add_action( 'admin_menu', array( $ideas_browser, 'on_register_menu' ) );
add_action( 'admin_menu', array( new PRAutoBlogger_Log_Viewer(), 'on_register_menu' ) );
add_action( 'admin_menu', [ $ideas_browser, 'on_register_menu' ] );
add_action( 'admin_menu', [ new PRAutoBlogger_Log_Viewer(), 'on_register_menu' ] );
( new PRAutoBlogger_Post_List_Columns() )->register();
// Block false update notifications — our slug collides with another plugin.
add_filter( 'site_transient_update_plugins', array( $this, 'filter_block_false_updates' ) );
add_filter( 'site_transient_update_plugins', [ $this, 'filter_block_false_updates' ] );
}
/** Register frontend hooks (shortcode, REST, typography). */
private function register_frontend_hooks(): void {
$posts_widget = new PRAutoBlogger_Posts_Widget();
add_action( 'init', array( $posts_widget, 'on_register_shortcode' ) );
add_action( 'rest_api_init', array( $posts_widget, 'on_register_rest_route' ) );
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', array( $typography, 'on_wp_head' ) );
add_action( 'wp_enqueue_scripts', array( $typography, 'on_enqueue_fonts' ) );
add_filter( 'the_content', array( $typography, 'on_wrap_tables' ), 99 );
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', array( $this->executor, 'on_daily_generation' ) );
add_action( 'prautoblogger_collect_metrics', array( $this->executor, 'on_collect_metrics' ) );
add_action( 'prautoblogger_daily_generation', [ $this->executor, 'on_daily_generation' ] );
add_action( 'prautoblogger_collect_metrics', [ $this->executor, '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', array( $this->executor, 'on_manual_generation' ) );
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,
array( $this->executor, 'on_process_article_queue' )
[ $this->executor, 'on_process_article_queue' ]
);
$registry = $this->executor->get_model_registry();
add_action( 'prautoblogger_refresh_model_registry', array( $registry, 'refresh' ) );
add_action( 'prautoblogger_refresh_model_registry', [ $registry, 'refresh' ] );
// Single-idea generation from the Ideas browser page.
add_action( 'prautoblogger_generate_from_idea', array( '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', array( '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']
)
);
}
);
}
add_action( 'prautoblogger_generate_from_idea', [ 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ] );
}
/** 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', array( $this->executor, 'on_ajax_generate_now' ) );
add_action( 'wp_ajax_prautoblogger_generation_status', array( $this->executor, 'on_ajax_generation_status' ) );
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', array( $this->ajax_handlers, 'on_ajax_generate_image' ) );
add_action( 'wp_ajax_prautoblogger_test_connection', array( $this->ajax_handlers, 'on_ajax_test_connection' ) );
add_action( 'wp_ajax_prautoblogger_get_models', array( $this->ajax_handlers, 'on_ajax_get_models' ) );
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' ] );
$review_queue = new PRAutoBlogger_Review_Queue();
add_action( 'wp_ajax_prautoblogger_approve_post', array( $review_queue, 'on_ajax_approve_post' ) );
add_action( 'wp_ajax_prautoblogger_reject_post', array( $review_queue, 'on_ajax_reject_post' ) );
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', array( new PRAutoBlogger_Log_Viewer(), 'on_ajax_clear_logs' ) );
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', array( $ideas, 'on_ajax_generate_from_idea' ) );
add_action( 'wp_ajax_prautoblogger_idea_gen_status', array( $ideas, 'on_ajax_idea_gen_status' ) );
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' ] );
}
/**
@@ -199,23 +174,16 @@ class PRAutoBlogger {
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 = array(
$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 );
];
$current = get_option( 'prautoblogger_image_style_suffix', '' );
$is_old = ( '' === $current );
foreach ( $known_old_prefixes as $prefix ) {
if ( false !== strpos( $current, $prefix ) ) {
$is_old = true;
@@ -227,6 +195,7 @@ class PRAutoBlogger {
}
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' ) ) {
@@ -238,13 +207,9 @@ class PRAutoBlogger {
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.
if ( ! get_option( 'prautoblogger_migrated_enc_prefix' ) ) {
$enc_options = array( '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 ( '' !== $val && ! PRAutoBlogger_Encryption::is_encrypted( $val ) ) {
@@ -262,10 +227,10 @@ class PRAutoBlogger {
* @return array<string, array{interval: int, display: string}>
*/
public function filter_add_cron_schedules( array $schedules ): array {
$schedules['prautoblogger_six_hours'] = array(
$schedules['prautoblogger_six_hours'] = [
'interval' => 6 * HOUR_IN_SECONDS,
'display' => __( 'Every Six Hours', 'prautoblogger' ),
);
];
return $schedules;
}
@@ -291,22 +256,17 @@ class PRAutoBlogger {
// ── Backward-compatible proxies ──
/** @see PRAutoBlogger_Executor::on_daily_generation() */
public function on_daily_generation(): void {
$this->executor->on_daily_generation(); }
public function on_daily_generation(): void { $this->executor->on_daily_generation(); }
/** @see PRAutoBlogger_Executor::on_collect_metrics() */
public function on_collect_metrics(): void {
$this->executor->on_collect_metrics(); }
public function on_collect_metrics(): void { $this->executor->on_collect_metrics(); }
/** @see PRAutoBlogger_Executor::on_ajax_generate_now() */
public function on_ajax_generate_now(): void {
$this->executor->on_ajax_generate_now(); }
public function on_ajax_generate_now(): void { $this->executor->on_ajax_generate_now(); }
/** @see PRAutoBlogger_Ajax_Handlers::on_ajax_test_connection() */
public function on_ajax_test_connection(): void {
$this->ajax_handlers->on_ajax_test_connection(); }
public function on_ajax_test_connection(): void { $this->ajax_handlers->on_ajax_test_connection(); }
/** @see PRAutoBlogger_Executor::get_model_registry() */
public function get_model_registry(): PRAutoBlogger_OpenRouter_Model_Registry {
return $this->executor->get_model_registry(); }
public function get_model_registry(): PRAutoBlogger_OpenRouter_Model_Registry { return $this->executor->get_model_registry(); }
}

View File

@@ -1,138 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* 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

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Prompt construction for the content analysis LLM call.
*
* What: Builds system and user prompts for source-data analysis, including
@@ -25,14 +23,14 @@ class PRAutoBlogger_Analysis_Prompts {
* @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';
$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 .= "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";
@@ -41,7 +39,7 @@ class PRAutoBlogger_Analysis_Prompts {
$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 .= "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";
@@ -83,7 +81,7 @@ class PRAutoBlogger_Analysis_Prompts {
* @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. '
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;
}
@@ -101,7 +99,7 @@ class PRAutoBlogger_Analysis_Prompts {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$top_posts = $wpdb->get_results(
"SELECT cs.post_id, cs.composite_score, p.post_title // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"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
@@ -114,7 +112,7 @@ class PRAutoBlogger_Analysis_Prompts {
return '';
}
$lines = array( 'Top performing past articles (learn from these):' );
$lines = [ 'Top performing past articles (learn from these):' ];
foreach ( $top_posts as $post ) {
$lines[] = sprintf(
'- "%s" (score: %.1f)',
@@ -138,29 +136,27 @@ class PRAutoBlogger_Analysis_Prompts {
* @return string Formatted context block, or empty string if no recent articles.
*/
public static function get_recent_articles_context(): string {
$query = new \WP_Query(
array(
'post_type' => 'post',
'post_status' => array( 'publish', 'draft', 'pending' ),
'meta_key' => '_prautoblogger_generated',
'meta_value' => '1',
'date_query' => array( array( 'after' => '30 days ago' ) ),
'posts_per_page' => 100,
'fields' => 'ids',
'no_found_rows' => true,
)
);
$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 = array();
$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 = "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 );

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Generates a single article: content → editorial review → publish/draft.
*
* Extracted from Pipeline_Runner so each chained cron job has a focused,
@@ -49,20 +47,20 @@ class PRAutoBlogger_Article_Worker {
$auto_publish = in_array(
get_option( 'prautoblogger_auto_publish', '0' ),
array( '1', 'yes' ),
[ '1', 'yes' ],
true
);
$result = array(
$result = [
'generated' => 0,
'published' => 0,
'rejected' => 0,
'cost' => 0.0,
);
];
try {
$this->broadcast_stage( __( 'Generating article draft via AI…', 'prautoblogger' ) );
$content = $generator->generate( $idea );
$content = $generator->generate( $idea );
$result['generated'] = 1;
$this->broadcast_stage( __( 'Running editorial pass…', 'prautoblogger' ) );
@@ -70,12 +68,7 @@ class PRAutoBlogger_Article_Worker {
$this->broadcast_stage( __( 'Saving and publishing…', 'prautoblogger' ) );
$this->publish_or_draft(
$content,
$idea,
$review,
$publisher,
$auto_publish,
$result
$content, $idea, $review, $publisher, $auto_publish, $result
);
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
@@ -121,13 +114,13 @@ class PRAutoBlogger_Article_Worker {
if ( $auto_publish ) {
$publisher->publish( $final, $idea, $review, $run_id );
++$result['published'];
$result['published']++;
} else {
$publisher->save_as_draft( $final, $idea, $review, $run_id );
}
} else {
$publisher->save_as_draft( $content, $idea, $review, $run_id );
++$result['rejected'];
$result['rejected']++;
PRAutoBlogger_Logger::instance()->info(
'Article rejected by editor: ' . $idea->get_topic(),
'pipeline'

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Chief Editor agent — LLM-powered editorial review of generated content.
*
* Reviews articles for quality, accuracy, SEO, and tone before publishing.
@@ -53,22 +51,16 @@ class PRAutoBlogger_Chief_Editor {
$user_prompt = $this->build_review_prompt( $content, $idea );
$response = $this->llm->send_chat_completion(
array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => $user_prompt,
),
),
[
[ 'role' => 'system', 'content' => $system_prompt ],
[ 'role' => 'user', 'content' => $user_prompt ],
],
$model,
array(
[
'temperature' => 0.3,
'max_tokens' => 5000,
'response_format' => array( 'type' => 'json_object' ),
)
'response_format' => [ 'type' => 'json_object' ],
]
);
$this->cost_tracker->log_api_call(
@@ -89,7 +81,7 @@ class PRAutoBlogger_Chief_Editor {
* @return string
*/
private function build_system_prompt( string $niche ): string {
$prompt = 'You are a senior blog editor';
$prompt = "You are a senior blog editor";
if ( '' !== $niche ) {
$prompt .= " specializing in {$niche} content";
}
@@ -169,31 +161,27 @@ class PRAutoBlogger_Chief_Editor {
. mb_substr( $content, 0, 500 ),
'editor'
);
return new PRAutoBlogger_Editorial_Review(
array(
'verdict' => 'rejected',
'notes' => 'Editor response could not be parsed.',
'quality_score' => 0.0,
'seo_score' => 0.0,
'issues' => array( 'Unparseable editor response' ),
)
);
return new PRAutoBlogger_Editorial_Review( [
'verdict' => 'rejected',
'notes' => 'Editor response could not be parsed.',
'quality_score' => 0.0,
'seo_score' => 0.0,
'issues' => [ 'Unparseable editor response' ],
] );
}
$verdict = sanitize_text_field( $data['verdict'] ?? 'rejected' );
if ( ! in_array( $verdict, array( 'approved', 'revised', 'rejected' ), true ) ) {
if ( ! in_array( $verdict, [ 'approved', 'revised', 'rejected' ], true ) ) {
$verdict = 'rejected';
}
return new PRAutoBlogger_Editorial_Review(
array(
'verdict' => $verdict,
'notes' => sanitize_textarea_field( $data['notes'] ?? '' ),
'revised_content' => isset( $data['revised_content'] ) ? wp_kses_post( $data['revised_content'] ) : null,
'quality_score' => (float) ( $data['quality_score'] ?? 0.0 ),
'seo_score' => (float) ( $data['seo_score'] ?? 0.0 ),
'issues' => array_map( 'sanitize_text_field', $data['issues'] ?? array() ),
)
);
return new PRAutoBlogger_Editorial_Review( [
'verdict' => $verdict,
'notes' => sanitize_textarea_field( $data['notes'] ?? '' ),
'revised_content' => isset( $data['revised_content'] ) ? wp_kses_post( $data['revised_content'] ) : null,
'quality_score' => (float) ( $data['quality_score'] ?? 0.0 ),
'seo_score' => (float) ( $data['seo_score'] ?? 0.0 ),
'issues' => array_map( 'sanitize_text_field', $data['issues'] ?? [] ),
] );
}
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Analyzes collected source data using an LLM to detect recurring patterns:
* questions people ask, complaints/pain points, and product comparisons.
*
@@ -51,7 +49,7 @@ class PRAutoBlogger_Content_Analyzer {
if ( empty( $source_items ) ) {
PRAutoBlogger_Logger::instance()->info( 'No new source data to analyze.', 'analyzer' );
return array();
return [];
}
$niche = get_option( 'prautoblogger_niche_description', '' );
@@ -63,22 +61,16 @@ class PRAutoBlogger_Content_Analyzer {
$user_prompt = PRAutoBlogger_Analysis_Prompts::build_user_prompt( $summary, $target_idea_count );
$response = $this->llm->send_chat_completion(
array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => $user_prompt,
),
),
[
[ 'role' => 'system', 'content' => $system_prompt ],
[ 'role' => 'user', 'content' => $user_prompt ],
],
$model,
array(
[
'temperature' => 0.5,
'max_tokens' => 8000,
'response_format' => array( 'type' => 'json_object' ),
)
'response_format' => [ 'type' => 'json_object' ],
]
);
$this->cost_tracker->log_api_call(
@@ -133,12 +125,12 @@ class PRAutoBlogger_Content_Analyzer {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
return $wpdb->get_results(
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare(
"SELECT * FROM {$table} WHERE collected_at >= %s ORDER BY score DESC LIMIT 200",
gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS )
),
ARRAY_A
) ?? array();
) ?: [];
}
/**
@@ -150,8 +142,8 @@ class PRAutoBlogger_Content_Analyzer {
* @return string Formatted text summary.
*/
private function build_source_summary( array $items ): string {
$parts = array();
$char_limit = 30000; // Roughly ~7500 tokens.
$parts = [];
$char_limit = 30000; // Roughly ~7500 tokens.
$current_chars = 0;
foreach ( $items as $item ) {
@@ -171,7 +163,7 @@ class PRAutoBlogger_Content_Analyzer {
break;
}
$parts[] = $entry;
$parts[] = $entry;
$current_chars += strlen( $entry );
}
@@ -193,29 +185,27 @@ class PRAutoBlogger_Content_Analyzer {
. mb_substr( $content, 0, 500 ),
'analyzer'
);
return array();
return [];
}
$source_ids = array_column( $source_items, 'id' );
$results = array();
$results = [];
foreach ( $data['patterns'] as $pattern ) {
$results[] = new PRAutoBlogger_Analysis_Result(
array(
'analysis_type' => sanitize_text_field( $pattern['type'] ?? 'question' ),
'topic' => sanitize_text_field( $pattern['topic'] ?? '' ),
'summary' => sanitize_textarea_field( $pattern['summary'] ?? '' ),
'frequency' => absint( $pattern['frequency'] ?? 1 ),
'relevance_score' => (float) ( $pattern['relevance_score'] ?? 0.0 ),
'source_ids' => $source_ids,
'analyzed_at' => current_time( 'mysql' ),
'metadata' => array(
'suggested_title' => $pattern['suggested_title'] ?? '',
'key_points' => $pattern['key_points'] ?? array(),
'target_keywords' => $pattern['target_keywords'] ?? array(),
),
)
);
$results[] = new PRAutoBlogger_Analysis_Result( [
'analysis_type' => sanitize_text_field( $pattern['type'] ?? 'question' ),
'topic' => sanitize_text_field( $pattern['topic'] ?? '' ),
'summary' => sanitize_textarea_field( $pattern['summary'] ?? '' ),
'frequency' => absint( $pattern['frequency'] ?? 1 ),
'relevance_score' => (float) ( $pattern['relevance_score'] ?? 0.0 ),
'source_ids' => $source_ids,
'analyzed_at' => current_time( 'mysql' ),
'metadata' => [
'suggested_title' => $pattern['suggested_title'] ?? '',
'key_points' => $pattern['key_points'] ?? [],
'target_keywords' => $pattern['target_keywords'] ?? [],
],
] );
}
return $results;

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Writer agent — generates blog post content from article ideas.
*
* Supports two configurable pipeline modes:
@@ -51,7 +49,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 ) ?? array(),
json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: [],
get_option( 'prautoblogger_writing_instructions', '' )
);
@@ -72,30 +70,17 @@ class PRAutoBlogger_Content_Generator {
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
$response = $this->llm->send_chat_completion(
array(
array(
'role' => 'system',
'content' => PRAutoBlogger_Content_Prompts::build_system( $request ),
),
array(
'role' => 'user',
'content' => PRAutoBlogger_Content_Prompts::build_single_pass( $request ),
),
),
[
[ 'role' => 'system', 'content' => PRAutoBlogger_Content_Prompts::build_system( $request ) ],
[ 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_single_pass( $request ) ],
],
$model,
array(
'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'];
@@ -117,30 +102,17 @@ class PRAutoBlogger_Content_Generator {
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
$response = $this->llm->send_chat_completion(
array(
array(
'role' => 'system',
'content' => $system,
),
array(
'role' => 'user',
'content' => PRAutoBlogger_Content_Prompts::build_outline( $request ),
),
),
[
[ 'role' => 'system', 'content' => $system ],
[ 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_outline( $request ) ],
],
$model,
array(
'temperature' => 0.5,
'max_tokens' => 1500,
)
[ 'temperature' => 0.5, 'max_tokens' => 1500 ]
);
$this->cost_tracker->log_api_call(
null,
'outline',
$this->llm->get_provider_name(),
$response['model'],
$response['prompt_tokens'],
$response['completion_tokens']
null, 'outline', $this->llm->get_provider_name(),
$response['model'], $response['prompt_tokens'], $response['completion_tokens']
);
return $response['content'];
@@ -151,30 +123,17 @@ class PRAutoBlogger_Content_Generator {
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
$response = $this->llm->send_chat_completion(
array(
array(
'role' => 'system',
'content' => $system,
),
array(
'role' => 'user',
'content' => PRAutoBlogger_Content_Prompts::build_draft( $request, $outline ),
),
),
[
[ 'role' => 'system', 'content' => $system ],
[ 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_draft( $request, $outline ) ],
],
$model,
array(
'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'];
@@ -185,30 +144,17 @@ class PRAutoBlogger_Content_Generator {
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
$response = $this->llm->send_chat_completion(
array(
array(
'role' => 'system',
'content' => $system,
),
array(
'role' => 'user',
'content' => PRAutoBlogger_Content_Prompts::build_polish( $draft ),
),
),
[
[ 'role' => 'system', 'content' => $system ],
[ 'role' => 'user', 'content' => PRAutoBlogger_Content_Prompts::build_polish( $draft ) ],
],
$model,
array(
'temperature' => 0.4,
'max_tokens' => 4000,
)
[ 'temperature' => 0.4, 'max_tokens' => 4000 ]
);
$this->cost_tracker->log_api_call(
null,
'polish',
$this->llm->get_provider_name(),
$response['model'],
$response['prompt_tokens'],
$response['completion_tokens']
null, 'polish', $this->llm->get_provider_name(),
$response['model'], $response['prompt_tokens'], $response['completion_tokens']
);
return $response['content'];

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Builds all LLM prompts used by the content generation pipeline.
*
* Centralises prompt construction so Content_Generator stays lean and
@@ -70,7 +68,7 @@ class PRAutoBlogger_Content_Prompts {
"- 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',
"- Follow EVERY formatting and structural requirement from your system prompt style guide",
$idea->get_suggested_title(),
$idea->get_topic(),
$idea->get_article_type(),
@@ -94,10 +92,10 @@ class PRAutoBlogger_Content_Prompts {
"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. ' .
"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.',
"Plan the structure to satisfy EVERY requirement in your system prompt style guide.",
$idea->get_suggested_title(),
$idea->get_topic(),
$idea->get_article_type(),
@@ -127,7 +125,7 @@ class PRAutoBlogger_Content_Prompts {
"- 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',
"- Follow EVERY formatting and structural requirement from your system prompt style guide",
$outline,
$request->get_tone(),
$request->get_min_word_count(),
@@ -149,9 +147,9 @@ class PRAutoBlogger_Content_Prompts {
"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 ' .
"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;
@@ -174,7 +172,7 @@ class PRAutoBlogger_Content_Prompts {
$rules .= self::build_article_links();
$rules .= '--- END LINKING RULES ---';
$rules .= "--- END LINKING RULES ---";
return $rules;
}
@@ -185,22 +183,20 @@ class PRAutoBlogger_Content_Prompts {
* @return string Formatted list, or a fallback note.
*/
private static function build_article_links(): string {
$posts = get_posts(
array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => 30,
'orderby' => 'date',
'order' => 'DESC',
'fields' => 'ids',
)
);
$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 = array( "Available article links (use where topically relevant):\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 );
@@ -211,4 +207,5 @@ class PRAutoBlogger_Content_Prompts {
return implode( "\n", $lines ) . "\n";
}
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Provides historical cost reporting and analysis methods.
*
* Extracted from PRAutoBlogger_Cost_Tracker, this class handles all read-only
@@ -37,7 +35,7 @@ class PRAutoBlogger_Cost_Reporter {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare(
"SELECT COALESCE(SUM(estimated_cost), 0) FROM {$table} WHERE created_at >= %s AND response_status = 'success'",
$first_of_month
)
@@ -56,7 +54,7 @@ class PRAutoBlogger_Cost_Reporter {
public function get_daily_spend( int $days = 30 ): array {
global $wpdb;
if ( null === $wpdb ) {
return array();
return [];
}
$table = $wpdb->prefix . 'prautoblogger_generation_log';
@@ -66,7 +64,7 @@ class PRAutoBlogger_Cost_Reporter {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"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)
@@ -76,8 +74,8 @@ class PRAutoBlogger_Cost_Reporter {
ARRAY_A
);
$daily = array();
foreach ( ( $results ?? array() ) as $row ) {
$daily = [];
foreach ( ( $results ?: [] ) as $row ) {
$daily[ $row['day'] ] = (float) $row['total_cost'];
}
@@ -95,7 +93,7 @@ class PRAutoBlogger_Cost_Reporter {
public function get_spend_by_stage( string $start_date, string $end_date ): array {
global $wpdb;
if ( null === $wpdb ) {
return array();
return [];
}
$table = $wpdb->prefix . 'prautoblogger_generation_log';
@@ -106,7 +104,7 @@ class PRAutoBlogger_Cost_Reporter {
"SELECT stage,
SUM(estimated_cost) as total_cost,
SUM(prompt_tokens + completion_tokens) as total_tokens,
COUNT(*) as call_count // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
COUNT(*) as call_count
FROM {$table}
WHERE created_at BETWEEN %s AND %s AND response_status = 'success'
GROUP BY stage",
@@ -116,13 +114,13 @@ class PRAutoBlogger_Cost_Reporter {
ARRAY_A
);
$breakdown = array();
foreach ( ( $results ?? array() ) as $row ) {
$breakdown[ $row['stage'] ] = array(
$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;

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Tracks all API costs and enforces monthly budget limits.
*
* Every LLM API call in the plugin is logged through this class. The budget
@@ -89,21 +87,19 @@ class PRAutoBlogger_Cost_Tracker {
$this->current_run_cost += $cost;
$log_entry = new PRAutoBlogger_Generation_Log(
array(
'post_id' => $post_id,
'run_id' => $this->run_id,
'stage' => $stage,
'provider' => $provider,
'model' => $model,
'prompt_tokens' => $prompt_tokens,
'completion_tokens' => $completion_tokens,
'estimated_cost' => $cost,
'response_status' => $response_status,
'error_message' => $error_message,
'created_at' => current_time( 'mysql' ),
)
);
$log_entry = new PRAutoBlogger_Generation_Log( [
'post_id' => $post_id,
'run_id' => $this->run_id,
'stage' => $stage,
'provider' => $provider,
'model' => $model,
'prompt_tokens' => $prompt_tokens,
'completion_tokens' => $completion_tokens,
'estimated_cost' => $cost,
'response_status' => $response_status,
'error_message' => $error_message,
'created_at' => current_time( 'mysql' ),
] );
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_generation_log';
@@ -162,22 +158,19 @@ class PRAutoBlogger_Cost_Tracker {
$table = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert(
$table,
array(
'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' ),
)
);
$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' ),
] );
}
/**

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Google Analytics 4 API client for fetching post performance metrics.
*
* Handles OAuth2 service account authentication and the GA4 Data API.
@@ -31,32 +29,32 @@ class PRAutoBlogger_GA4_Client {
public function fetch_data( array $posts ): array {
$property_id = get_option( 'prautoblogger_ga4_property_id', '' );
if ( '' === $property_id ) {
return array();
return [];
}
$credentials_encrypted = get_option( 'prautoblogger_ga4_credentials_json', '' );
if ( '' === $credentials_encrypted ) {
return array();
return [];
}
$credentials = PRAutoBlogger_Encryption::decrypt( $credentials_encrypted );
if ( '' === $credentials ) {
return array();
return [];
}
// Build page paths for the GA4 query.
$page_paths = array();
$path_to_id = array();
$page_paths = [];
$path_to_id = [];
foreach ( $posts as $post ) {
$path = wp_parse_url( get_permalink( $post->ID ), PHP_URL_PATH );
if ( $path ) {
$page_paths[] = $path;
$page_paths[] = $path;
$path_to_id[ $path ] = (int) $post->ID;
}
}
if ( empty( $page_paths ) ) {
return array();
return [];
}
// GA4 Data API request.
@@ -64,45 +62,40 @@ class PRAutoBlogger_GA4_Client {
$access_token = $this->get_access_token( $credentials );
if ( '' === $access_token ) {
PRAutoBlogger_Logger::instance()->error( 'GA4: Failed to get access token. Possible causes: invalid service account JSON, expired or revoked private key, or network error reaching oauth2.googleapis.com. Verify credentials in PRAutoBlogger → Settings.', 'ga4' );
return array();
return [];
}
$request_body = array(
'dateRanges' => array(
array(
'startDate' => '30daysAgo',
'endDate' => 'today',
),
),
'dimensions' => array( array( 'name' => 'pagePath' ) ),
'metrics' => array(
array( 'name' => 'screenPageViews' ),
array( 'name' => 'averageSessionDuration' ),
array( 'name' => 'bounceRate' ),
),
'dimensionFilter' => array(
'filter' => array(
$request_body = [
'dateRanges' => [ [ 'startDate' => '30daysAgo', 'endDate' => 'today' ] ],
'dimensions' => [ [ 'name' => 'pagePath' ] ],
'metrics' => [
[ 'name' => 'screenPageViews' ],
[ 'name' => 'averageSessionDuration' ],
[ 'name' => 'bounceRate' ],
],
'dimensionFilter' => [
'filter' => [
'fieldName' => 'pagePath',
'inListFilter' => array( 'values' => $page_paths ),
),
),
);
'inListFilter' => [ 'values' => $page_paths ],
],
],
];
$response = wp_remote_post(
"https://analyticsdata.googleapis.com/v1beta/properties/{$property_id}:runReport",
array(
[
'timeout' => 30,
'headers' => array(
'headers' => [
'Authorization' => 'Bearer ' . $access_token,
'Content-Type' => 'application/json',
),
],
'body' => wp_json_encode( $request_body ),
)
]
);
if ( is_wp_error( $response ) ) {
PRAutoBlogger_Logger::instance()->error( 'GA4 API request failed (WP HTTP error): ' . $response->get_error_message(), 'ga4' );
return array();
return [];
}
$ga4_status = wp_remote_retrieve_response_code( $response );
@@ -112,20 +105,20 @@ class PRAutoBlogger_GA4_Client {
sprintf( 'GA4 API request failed (HTTP %d): %s', $ga4_status, substr( $ga4_body, 0, 500 ) ),
'ga4'
);
return array();
return [];
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
$results = array();
$results = [];
foreach ( ( $data['rows'] ?? array() ) as $row ) {
foreach ( ( $data['rows'] ?? [] ) as $row ) {
$path = $row['dimensionValues'][0]['value'] ?? '';
if ( isset( $path_to_id[ $path ] ) ) {
$results[ $path_to_id[ $path ] ] = array(
$results[ $path_to_id[ $path ] ] = [
'pageviews' => (int) ( $row['metricValues'][0]['value'] ?? 0 ),
'avg_time_on_page' => (float) ( $row['metricValues'][1]['value'] ?? 0 ),
'bounce_rate' => (float) ( $row['metricValues'][2]['value'] ?? 0 ),
);
];
}
}
@@ -147,26 +140,19 @@ class PRAutoBlogger_GA4_Client {
// Build JWT for service account auth.
$now = time();
$header = wp_json_encode(
array(
'alg' => 'RS256',
'typ' => 'JWT',
)
);
$claim = wp_json_encode(
array(
'iss' => $creds['client_email'],
'scope' => 'https://www.googleapis.com/auth/analytics.readonly',
'aud' => 'https://oauth2.googleapis.com/token',
'exp' => $now + 3600,
'iat' => $now,
)
);
$header = wp_json_encode( [ 'alg' => 'RS256', 'typ' => 'JWT' ] );
$claim = wp_json_encode( [
'iss' => $creds['client_email'],
'scope' => 'https://www.googleapis.com/auth/analytics.readonly',
'aud' => 'https://oauth2.googleapis.com/token',
'exp' => $now + 3600,
'iat' => $now,
] );
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
$base64_header = rtrim( strtr( base64_encode( $header ), '+/', '-_' ), '=' );
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
$base64_claim = rtrim( strtr( base64_encode( $claim ), '+/', '-_' ), '=' );
$base64_claim = rtrim( strtr( base64_encode( $claim ), '+/', '-_' ), '=' );
$signing_input = $base64_header . '.' . $base64_claim;
$signature = '';
@@ -185,13 +171,13 @@ class PRAutoBlogger_GA4_Client {
$response = wp_remote_post(
'https://oauth2.googleapis.com/token',
array(
[
'timeout' => 15,
'body' => array(
'body' => [
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
),
)
],
]
);
if ( is_wp_error( $response ) ) {

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Ranks and deduplicates article ideas from analysis results.
*
* Uses PRAutoBlogger_Semantic_Dedup for similarity detection — embedding-based
@@ -46,8 +44,8 @@ class PRAutoBlogger_Idea_Scorer {
* @return PRAutoBlogger_Article_Idea[] Top-ranked ideas, sorted by score desc.
*/
public function score_and_rank( array $analysis_results, int $target_count ): array {
$exclusions = json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?? array();
$ideas = array();
$exclusions = json_decode( get_option( 'prautoblogger_topic_exclusions', '[]' ), true ) ?: [];
$ideas = [];
$excluded_count = 0;
$deduped_count = 0;
@@ -57,11 +55,11 @@ class PRAutoBlogger_Idea_Scorer {
}
// Keyword sets kept for fallback (passed through to Semantic_Dedup).
$accepted_keyword_sets = array();
$accepted_keyword_sets = [];
foreach ( $analysis_results as $result ) {
if ( $this->is_excluded( $result->get_topic(), $exclusions ) ) {
++$excluded_count;
$excluded_count++;
continue;
}
@@ -70,7 +68,7 @@ class PRAutoBlogger_Idea_Scorer {
$topic_keywords = $this->extract_meaningful_keywords( $topic );
if ( $dedup->is_batch_duplicate( $topic, $topic_keywords, $accepted_keyword_sets ) ) {
++$deduped_count;
$deduped_count++;
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Dedup(batch): skipping "%s"', substr( $topic, 0, 80 ) ),
'scorer'
@@ -79,7 +77,7 @@ class PRAutoBlogger_Idea_Scorer {
}
if ( $dedup->is_recent_duplicate( $topic, $topic_keywords ) ) {
++$deduped_count;
$deduped_count++;
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Dedup(recent): skipping "%s"', substr( $topic, 0, 80 ) ),
'scorer'
@@ -91,21 +89,19 @@ class PRAutoBlogger_Idea_Scorer {
$dedup->record_accepted( $topic );
}
$metadata = $result->get_metadata() ?? array();
$metadata = $result->get_metadata() ?? [];
$ideas[] = new PRAutoBlogger_Article_Idea(
array(
'topic' => $result->get_topic(),
'article_type' => $this->map_type( $result->get_analysis_type() ),
'suggested_title' => $metadata['suggested_title'] ?? $result->get_topic(),
'summary' => $result->get_summary() ?? '',
'score' => $this->compute_score( $result ),
'analysis_id' => $result->get_id(),
'source_ids' => $result->get_source_ids(),
'key_points' => $metadata['key_points'] ?? array(),
'target_keywords' => $metadata['target_keywords'] ?? array(),
)
);
$ideas[] = new PRAutoBlogger_Article_Idea( [
'topic' => $result->get_topic(),
'article_type' => $this->map_type( $result->get_analysis_type() ),
'suggested_title' => $metadata['suggested_title'] ?? $result->get_topic(),
'summary' => $result->get_summary() ?? '',
'score' => $this->compute_score( $result ),
'analysis_id' => $result->get_id(),
'source_ids' => $result->get_source_ids(),
'key_points' => $metadata['key_points'] ?? [],
'target_keywords' => $metadata['target_keywords'] ?? [],
] );
}
PRAutoBlogger_Logger::instance()->info(
@@ -120,12 +116,9 @@ class PRAutoBlogger_Idea_Scorer {
'scorer'
);
usort(
$ideas,
static function ( PRAutoBlogger_Article_Idea $a, PRAutoBlogger_Article_Idea $b ): int {
return $b->get_score() <=> $a->get_score();
}
);
usort( $ideas, static function ( PRAutoBlogger_Article_Idea $a, PRAutoBlogger_Article_Idea $b ): int {
return $b->get_score() <=> $a->get_score();
} );
return array_slice( $ideas, 0, $target_count );
}
@@ -172,122 +165,27 @@ class PRAutoBlogger_Idea_Scorer {
* @return string[] Unique lowercase keywords.
*/
private function extract_meaningful_keywords( string $text ): array {
static $stopwords = array(
'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',
);
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 );
}
);
$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 ) );
}
@@ -299,13 +197,13 @@ class PRAutoBlogger_Idea_Scorer {
* @return string Article type label.
*/
private function map_type( string $analysis_type ): string {
$map = array(
$map = [
'question' => 'guide',
'complaint' => 'solution',
'comparison' => 'comparison',
'news' => 'news',
'guide' => 'guide',
);
];
return $map[ $analysis_type ] ?? 'article';
}
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Downloads generated images and imports them into the WordPress media library.
*
* Handles sideloading (importing external images as WordPress attachments),
@@ -63,10 +61,10 @@ class PRAutoBlogger_Image_Media_Sideloader {
}
// Prepare file array for media_handle_sideload().
$file_array = array(
$file_array = [
'name' => $this->generate_filename( $image_data['model'], $image_data['width'], $image_data['height'] ),
'tmp_name' => $temp_file,
);
];
// Import the file into media library.
$attachment_id = media_handle_sideload( $file_array, $post_id );
@@ -110,8 +108,8 @@ 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 ) {
$temp_dir = get_temp_dir();
$filename = 'prautoblogger_img_' . wp_generate_uuid4() . $this->get_extension_for_mime( $mime_type );
$temp_dir = get_temp_dir();
$filename = 'prautoblogger_img_' . wp_generate_uuid4() . $this->get_extension_for_mime( $mime_type );
$temp_path = $temp_dir . $filename;
$written = file_put_contents( $temp_path, $bytes );
@@ -158,11 +156,11 @@ class PRAutoBlogger_Image_Media_Sideloader {
* @return string File extension with leading dot (e.g., '.png').
*/
private function get_extension_for_mime( string $mime_type ): string {
$map = array(
$map = [
'image/png' => '.png',
'image/jpeg' => '.jpg',
'image/jpg' => '.jpg',
);
];
return $map[ strtolower( $mime_type ) ] ?? '.png';
}

View File

@@ -2,12 +2,10 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* 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
* When an image provider (currently only Cloudflare Workers AI) 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.
@@ -108,15 +106,15 @@ class PRAutoBlogger_Image_NSFW_Retry {
'image_pipeline'
);
$retry_request = array(
$retry_request = [
'prompt' => $fallback['prompt'],
'width' => (int) ( $batch_requests[ $key ]['width'] ?? 0 ),
'width' => (int) ( $batch_requests[ $key ]['width'] ?? 0 ),
'height' => (int) ( $batch_requests[ $key ]['height'] ?? 0 ),
'options' => $batch_requests[ $key ]['options'] ?? array(),
);
'options' => $batch_requests[ $key ]['options'] ?? [],
];
$retry_results = $this->provider->generate_image_batch( array( $key => $retry_request ) );
$retry_entry = $retry_results[ $key ] ?? array( 'error' => 'Retry provider returned no result.' );
$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(

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Orchestrates image generation for published articles.
*
* Generates two images per article (A/B): Image A (article-driven) as featured
@@ -19,19 +17,22 @@ declare(strict_types=1);
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).
*/
// Dimensions must be divisible by 8 (required by some providers).
// 1200×632 is the closest 8-aligned pair to the standard OG image size (1200×630).
private const DEFAULT_WIDTH = 1200;
private const DEFAULT_HEIGHT = 632;
/** @var PRAutoBlogger_Image_Provider_Interface Image gen provider. */
private PRAutoBlogger_Image_Provider_Interface $provider;
/** @var PRAutoBlogger_Image_Prompt_Builder Builds scene + caption from article data. */
private PRAutoBlogger_Image_Prompt_Builder $prompt_builder;
/** @var PRAutoBlogger_Image_Media_Sideloader Downloads images into WP media library. */
private PRAutoBlogger_Image_Media_Sideloader $sideloader;
/** @var PRAutoBlogger_Cost_Tracker Logs image generation spend. */
private PRAutoBlogger_Cost_Tracker $cost_tracker;
@@ -40,7 +41,7 @@ class PRAutoBlogger_Image_Pipeline {
*
* When no provider is injected, the constructor reads the
* `prautoblogger_image_provider` setting and instantiates the
* matching concrete class ('runware' or 'openrouter').
* matching concrete class ('openrouter' or 'cloudflare').
*
* @param PRAutoBlogger_Image_Provider_Interface|null $provider Optional provider override.
* @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker.
@@ -49,15 +50,14 @@ class PRAutoBlogger_Image_Pipeline {
?PRAutoBlogger_Image_Provider_Interface $provider = null,
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
) {
$this->provider = $provider ?? self::create_default_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();
$this->provider = $provider ?? self::create_default_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).
* Instantiate the image provider based on the admin setting.
*
* @return PRAutoBlogger_Image_Provider_Interface
*/
@@ -66,7 +66,7 @@ class PRAutoBlogger_Image_Pipeline {
if ( 'openrouter' === $provider_id ) {
return new PRAutoBlogger_OpenRouter_Image_Provider();
}
return new PRAutoBlogger_Runware_Image_Provider();
return new PRAutoBlogger_Cloudflare_Image_Provider();
}
/**
@@ -87,10 +87,7 @@ class PRAutoBlogger_Image_Pipeline {
array $article_data,
?array $source_data = null
): array {
$result = array(
'cost_usd' => 0.0,
'errors' => array(),
);
$result = [ 'cost_usd' => 0.0, 'errors' => [] ];
if ( ! get_option( 'prautoblogger_image_enabled' ) ) {
PRAutoBlogger_Logger::instance()->info( 'Image generation disabled in settings.', 'image_pipeline' );
@@ -112,24 +109,24 @@ class PRAutoBlogger_Image_Pipeline {
// Build all prompts upfront (LLM calls happen here, sequentially).
$article_prompt = $this->prompt_builder->build_article_prompt( $article_data );
$batch_requests = array(
'image_a' => array(
$batch_requests = [
'image_a' => [
'prompt' => $article_prompt['prompt'],
'width' => self::DEFAULT_WIDTH,
'height' => self::DEFAULT_HEIGHT,
),
);
$captions = array( 'image_a' => $article_prompt['caption'] );
],
];
$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'] = array(
$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'];
];
$captions['image_b'] = $source_prompt['caption'];
}
// Fire all image generation requests in parallel.
@@ -295,11 +292,9 @@ class PRAutoBlogger_Image_Pipeline {
esc_html( $caption )
);
wp_update_post(
array(
'ID' => $post_id,
'post_content' => $caption_html . "\n\n" . $post->post_content,
)
);
wp_update_post( [
'ID' => $post_id,
'post_content' => $caption_html . "\n\n" . $post->post_content,
] );
}
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Builds image prompts from article content or source data.
*
* Uses a cheap LLM (via OpenRouter) to distill article content into concise
@@ -33,13 +31,12 @@ You are a comedy writer and single-panel cartoon creator, like Gary Larson (The
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.
SCENE: One sentence describing the visual gag — a funny, absurd, or ironic situation related to the article's topic. Use anthropomorphized molecules, lab-coat-wearing animals, befuddled scientists, gym bros encountering peptide science, supplement bottles with personality, or everyday situations with a peptide twist. 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
@@ -47,9 +44,9 @@ Rules:
- 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.
A muscular gym bro stares in confusion at a tiny vial while a peptide molecule with arms and legs flexes next to him, clearly outperforming him.
"They told me the peptide did the work, not the squats."
"I've been doing this wrong for three years."
PROMPT;
/**
@@ -84,10 +81,10 @@ PROMPT;
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
return array(
return [
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
'caption' => $parsed['caption'],
);
];
}
/**
@@ -99,17 +96,17 @@ PROMPT;
*/
public function build_source_prompt( array $source_data ): array {
$title = $source_data['title'] ?? 'Reddit Discussion';
$comments = $source_data['comments'] ?? array();
$comments = $source_data['comments'] ?? [];
$context = is_array( $comments ) && ! empty( $comments ) ? $comments[0] : '';
$parsed = $this->rewrite_via_llm( $title, $context );
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
return array(
return [
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
'caption' => $parsed['caption'],
);
];
}
/**
@@ -148,21 +145,15 @@ PROMPT;
$system = $this->resolve_system_prompt();
$result = $llm->send_chat_completion(
array(
array(
'role' => 'system',
'content' => $system,
),
array(
'role' => 'user',
'content' => $user_message,
),
),
[
[ 'role' => 'system', 'content' => $system ],
[ 'role' => 'user', 'content' => $user_message ],
],
$model,
array(
[
'temperature' => 0.7,
'max_tokens' => self::REWRITER_MAX_TOKENS,
)
]
);
$raw = trim( $result['content'] ?? '' );
@@ -225,17 +216,11 @@ PROMPT;
$caption = trim( $parts[1] );
// Strip surrounding quotes from caption — the LLM wraps it in quotes.
$caption = trim( $caption, '"\'' );
return array(
'scene' => $scene,
'caption' => $caption,
);
return [ 'scene' => $scene, 'caption' => $caption ];
}
// No blank-line separator found — treat entire response as scene.
return array(
'scene' => trim( $raw ),
'caption' => '',
);
return [ 'scene' => trim( $raw ), 'caption' => '' ];
}
/**
@@ -270,10 +255,10 @@ PROMPT;
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 array(
return [
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
'caption' => $parsed['caption'],
);
];
}
/**
@@ -292,10 +277,10 @@ PROMPT;
$title
);
return array(
return [
'scene' => trim( $scene ),
'caption' => 'Science is full of surprises.',
);
];
}
/** Lazy-load the OpenRouter provider. */

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Extracts valid JSON from potentially dirty LLM response strings.
*
* Many models ignore the `response_format: json_object` instruction and wrap

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Structured logging with configurable verbosity levels.
*
* Writes log entries to a custom database table and optionally forwards
@@ -28,19 +26,19 @@ class PRAutoBlogger_Logger {
public const LEVEL_INFO = 2;
public const LEVEL_DEBUG = 3;
private const LEVEL_MAP = array(
private const LEVEL_MAP = [
'error' => self::LEVEL_ERROR,
'warning' => self::LEVEL_WARNING,
'info' => self::LEVEL_INFO,
'debug' => self::LEVEL_DEBUG,
);
];
private const LEVEL_LABELS = array(
private const LEVEL_LABELS = [
self::LEVEL_ERROR => 'error',
self::LEVEL_WARNING => 'warning',
self::LEVEL_INFO => 'info',
self::LEVEL_DEBUG => 'debug',
);
];
/** Singleton instance. */
private static ?self $instance = null;
@@ -68,7 +66,7 @@ class PRAutoBlogger_Logger {
* @param string $context Origin class or pipeline stage.
* @param array<string, mixed> $meta Optional structured data.
*/
public function error( string $message, string $context = '', array $meta = array() ): void {
public function error( string $message, string $context = '', array $meta = [] ): void {
$this->log( self::LEVEL_ERROR, $message, $context, $meta );
}
@@ -79,7 +77,7 @@ class PRAutoBlogger_Logger {
* @param string $context
* @param array<string, mixed> $meta
*/
public function warning( string $message, string $context = '', array $meta = array() ): void {
public function warning( string $message, string $context = '', array $meta = [] ): void {
$this->log( self::LEVEL_WARNING, $message, $context, $meta );
}
@@ -90,7 +88,7 @@ class PRAutoBlogger_Logger {
* @param string $context
* @param array<string, mixed> $meta
*/
public function info( string $message, string $context = '', array $meta = array() ): void {
public function info( string $message, string $context = '', array $meta = [] ): void {
$this->log( self::LEVEL_INFO, $message, $context, $meta );
}
@@ -101,7 +99,7 @@ class PRAutoBlogger_Logger {
* @param string $context
* @param array<string, mixed> $meta
*/
public function debug( string $message, string $context = '', array $meta = array() ): void {
public function debug( string $message, string $context = '', array $meta = [] ): void {
$this->log( self::LEVEL_DEBUG, $message, $context, $meta );
}
@@ -136,16 +134,13 @@ class PRAutoBlogger_Logger {
$table = $wpdb->prefix . 'prautoblogger_event_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert(
$table,
array(
'level' => $label,
'context' => substr( $context, 0, 100 ),
'message' => substr( $message, 0, 2000 ),
'meta_json' => ! empty( $meta ) ? wp_json_encode( $meta ) : null,
'created_at' => current_time( 'mysql' ),
)
);
$wpdb->insert( $table, [
'level' => $label,
'context' => substr( $context, 0, 100 ),
'message' => substr( $message, 0, 2000 ),
'meta_json' => ! empty( $meta ) ? wp_json_encode( $meta ) : null,
'created_at' => current_time( 'mysql' ),
] );
}
/**
@@ -166,8 +161,8 @@ class PRAutoBlogger_Logger {
): array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_event_log';
$where = array();
$params = array();
$where = [];
$params = [];
if ( 'all' !== $level_filter && isset( self::LEVEL_MAP[ $level_filter ] ) ) {
$where[] = 'level = %s';
@@ -186,10 +181,10 @@ class PRAutoBlogger_Logger {
// Total count.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
? "SELECT COUNT(*) FROM {$table} {$where_sql}" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
: $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$total = (int) $wpdb->get_var(
empty( $params )
? "SELECT COUNT(*) FROM {$table} {$where_sql}"
: $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params )
);
// Rows.
@@ -199,17 +194,14 @@ class PRAutoBlogger_Logger {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare(
"SELECT * FROM {$table} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d",
...$query_params
),
ARRAY_A
);
return array(
'rows' => $rows ?? array(),
'total' => $total,
);
return [ 'rows' => $rows ?: [], 'total' => $total ];
}
/**
@@ -224,7 +216,7 @@ class PRAutoBlogger_Logger {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
return (int) $wpdb->query(
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare(
"DELETE FROM {$table} WHERE created_at < %s",
gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) )
)

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Collects post performance metrics from WordPress native data and Google Analytics 4.
*
* Runs on a separate cron schedule (every 6 hours) to gather pageviews, bounce rate,
@@ -37,8 +35,8 @@ class PRAutoBlogger_Metrics_Collector {
}
$ga4_client = new PRAutoBlogger_GA4_Client();
$ga4_data = $ga4_client->fetch_data( $posts );
$scored = 0;
$ga4_data = $ga4_client->fetch_data( $posts );
$scored = 0;
foreach ( $posts as $post ) {
$post_id = (int) $post->ID;
@@ -49,11 +47,11 @@ class PRAutoBlogger_Metrics_Collector {
continue;
}
$ga_metrics = $ga4_data[ $post_id ] ?? array();
$ga_metrics = $ga4_data[ $post_id ] ?? [];
$score = $this->compute_composite_score( $wp_metrics, $ga_metrics, $post );
$this->store_score( $post_id, $wp_metrics, $ga_metrics, $score );
++$scored;
$scored++;
}
PRAutoBlogger_Logger::instance()->info( sprintf( 'Metrics collected for %d posts.', $scored ), 'metrics' );
@@ -66,17 +64,15 @@ class PRAutoBlogger_Metrics_Collector {
* @return \WP_Post[]
*/
private function get_generated_posts(): array {
$query = new \WP_Query(
array(
'post_type' => 'post',
'post_status' => 'publish',
'meta_key' => '_prautoblogger_generated',
'meta_value' => '1',
'posts_per_page' => 100,
'orderby' => 'date',
'order' => 'DESC',
)
);
$query = new \WP_Query( [
'post_type' => 'post',
'post_status' => 'publish',
'meta_key' => '_prautoblogger_generated',
'meta_value' => '1',
'posts_per_page' => 100,
'orderby' => 'date',
'order' => 'DESC',
] );
return $query->posts;
}
@@ -97,10 +93,10 @@ class PRAutoBlogger_Metrics_Collector {
return null;
}
return array(
return [
'comment_count' => (int) ( $post->comment_count ?? 0 ),
'days_published' => max( 1, (int) ( ( time() - strtotime( $post->post_date ) ) / DAY_IN_SECONDS ) ),
);
];
}
@@ -114,10 +110,10 @@ class PRAutoBlogger_Metrics_Collector {
* @return array{score: float, factors: array<string, float>}
*/
private function compute_composite_score( array $wp_metrics, array $ga_metrics, \WP_Post $post ): array {
$factors = array();
$factors = [];
// Comment engagement (0-25 points).
$comments_per_day = $wp_metrics['comment_count'] / max( 1, $wp_metrics['days_published'] );
$comments_per_day = $wp_metrics['comment_count'] / max( 1, $wp_metrics['days_published'] );
$factors['comments'] = min( 25.0, $comments_per_day * 25.0 );
// Pageview performance (0-30 points, if GA4 available).
@@ -146,10 +142,10 @@ class PRAutoBlogger_Metrics_Collector {
$total = array_sum( $factors );
return array(
return [
'score' => round( $total, 2 ),
'factors' => $factors,
);
];
}
/**
@@ -166,18 +162,16 @@ class PRAutoBlogger_Metrics_Collector {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_content_scores';
$entry = new PRAutoBlogger_Content_Score(
array(
'post_id' => $post_id,
'pageviews' => $ga_metrics['pageviews'] ?? 0,
'avg_time_on_page' => $ga_metrics['avg_time_on_page'] ?? 0.0,
'bounce_rate' => $ga_metrics['bounce_rate'] ?? 0.0,
'comment_count' => $wp_metrics['comment_count'] ?? 0,
'composite_score' => $score_data['score'],
'score_factors' => $score_data['factors'],
'measured_at' => current_time( 'mysql' ),
)
);
$entry = new PRAutoBlogger_Content_Score( [
'post_id' => $post_id,
'pageviews' => $ga_metrics['pageviews'] ?? 0,
'avg_time_on_page' => $ga_metrics['avg_time_on_page'] ?? 0.0,
'bounce_rate' => $ga_metrics['bounce_rate'] ?? 0.0,
'comment_count' => $wp_metrics['comment_count'] ?? 0,
'composite_score' => $score_data['score'],
'score_factors' => $score_data['factors'],
'measured_at' => current_time( 'mysql' ),
] );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert( $table, $entry->to_db_row() );

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Deterministic post-processor that hyperlinks peptide mentions in HTML content.
*
* Scans generated article HTML for any peptide name that exists in the PR Core
@@ -40,19 +38,13 @@ class PRAutoBlogger_Peptide_Linker {
// 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 );
}
);
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
);
$escaped = array_map( static function ( string $name ): string {
return preg_quote( $name, '/' );
}, $names );
// Word-boundary match, case-insensitive.
$pattern = '/\b(' . implode( '|', $escaped ) . ')\b/iu';
@@ -71,18 +63,16 @@ class PRAutoBlogger_Peptide_Linker {
* @return array<string, string> Name → URL pairs, case-preserved keys.
*/
private static function build_link_map(): array {
$peptides = get_posts(
array(
'post_type' => 'pr_peptide',
'post_status' => 'publish',
'posts_per_page' => 200,
'orderby' => 'title',
'order' => 'ASC',
'fields' => 'ids',
)
);
$peptides = get_posts( [
'post_type' => 'pr_peptide',
'post_status' => 'publish',
'posts_per_page' => 200,
'orderby' => 'title',
'order' => 'ASC',
'fields' => 'ids',
] );
$map = array();
$map = [];
foreach ( $peptides as $peptide_id ) {
$title = get_the_title( $peptide_id );
$url = get_permalink( $peptide_id );
@@ -96,7 +86,7 @@ class PRAutoBlogger_Peptide_Linker {
// 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 );
$space_variant = str_replace( '-', ' ', $title );
$map[ $space_variant ] = $url;
}
}
@@ -125,7 +115,7 @@ class PRAutoBlogger_Peptide_Linker {
}
// Build a lowercase → URL lookup for case-insensitive matching.
$lower_map = array();
$lower_map = [];
foreach ( $link_map as $name => $url ) {
$lower_map[ mb_strtolower( $name ) ] = $url;
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Executes the content generation pipeline using a chained-job architecture.
*
* Orchestrates: Budget check → Collect source data → Analyze patterns →
@@ -65,12 +63,7 @@ class PRAutoBlogger_Pipeline_Runner {
$ideas = $this->orchestrate( $cost_tracker );
if ( empty( $ideas ) ) {
return array(
'generated' => 0,
'published' => 0,
'rejected' => 0,
'cost' => 0.0,
);
return [ 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ];
}
// Generate article 1 in this process — always fits within time limit.
@@ -163,7 +156,7 @@ class PRAutoBlogger_Pipeline_Runner {
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 );
$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 ) );
@@ -204,11 +197,11 @@ class PRAutoBlogger_Pipeline_Runner {
$ideas
);
$queue = array(
$queue = [
'run_id' => $run_id,
'ideas' => $serialized,
'results' => $first,
);
];
update_option( self::QUEUE_KEY, $queue, false );
$this->update_queue_status( $queue );
@@ -228,11 +221,7 @@ class PRAutoBlogger_Pipeline_Runner {
spawn_cron();
wp_remote_post(
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
array(
'timeout' => 0.01,
'blocking' => false,
'sslverify' => false,
)
[ 'timeout' => 0.01, 'blocking' => false, 'sslverify' => false ]
);
}
@@ -242,31 +231,23 @@ class PRAutoBlogger_Pipeline_Runner {
$total = $done + count( $queue['ideas'] );
$current = get_transient( 'prautoblogger_generation_status' );
set_transient(
'prautoblogger_generation_status',
array(
'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
);
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',
array(
'status' => 'complete',
'generated' => $r['generated'],
'published' => $r['published'],
'rejected' => $r['rejected'],
'cost' => $r['cost'],
),
600
);
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. */
@@ -284,10 +265,7 @@ class PRAutoBlogger_Pipeline_Runner {
PRAutoBlogger_Logger::instance()->info(
sprintf(
'Pipeline complete: %d generated, %d published, %d rejected. Cost: $%.4f',
$r['generated'],
$r['published'],
$r['rejected'],
$r['cost']
$r['generated'], $r['published'], $r['rejected'], $r['cost']
),
'pipeline'
);

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Post-creation assembly helpers: taxonomy, log linking, images, content sanitization.
*
* What: After wp_insert_post, assigns categories/tags, links generation logs,
@@ -23,12 +21,12 @@ class PRAutoBlogger_Post_Assembler {
* @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 = array(
$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' );
@@ -36,10 +34,10 @@ class PRAutoBlogger_Post_Assembler {
if ( ! $category ) {
$result = wp_insert_term( $category_name, 'category' );
if ( ! is_wp_error( $result ) ) {
wp_set_post_categories( $post_id, array( $result['term_id'] ) );
wp_set_post_categories( $post_id, [ $result['term_id'] ] );
}
} else {
wp_set_post_categories( $post_id, array( $category->term_id ) );
wp_set_post_categories( $post_id, [ $category->term_id ] );
}
$keywords = $idea->get_target_keywords();
@@ -65,7 +63,7 @@ class PRAutoBlogger_Post_Assembler {
// to a single post. See amortize_research_costs().
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$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
@@ -75,7 +73,7 @@ class PRAutoBlogger_Post_Assembler {
// Legacy fallback: timestamp-based linking for pre-migration log entries.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$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 )
@@ -109,7 +107,7 @@ class PRAutoBlogger_Post_Assembler {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$research_row = $wpdb->get_row(
$wpdb->prepare(
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"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",
@@ -124,7 +122,7 @@ class PRAutoBlogger_Post_Assembler {
// Count distinct articles produced in this run.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$post_ids = $wpdb->get_col(
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare(
"SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL",
$run_id
)
@@ -135,37 +133,34 @@ class PRAutoBlogger_Post_Assembler {
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 );
$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,
array(
'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' ),
)
);
$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, array( 'id' => (int) $research_row->id ) );
$wpdb->delete( $table, [ 'id' => (int) $research_row->id ] );
PRAutoBlogger_Logger::instance()->info(
sprintf(
@@ -218,13 +213,7 @@ class PRAutoBlogger_Post_Assembler {
return $author_id;
}
$admins = get_users(
array(
'role' => 'administrator',
'number' => 1,
'fields' => 'ID',
)
);
$admins = get_users( [ 'role' => 'administrator', 'number' => 1, 'fields' => 'ID' ] );
return ! empty( $admins ) ? (int) $admins[0] : 1;
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Creates WordPress posts from generated and editor-approved content.
*
* Handles both publishing (editor-approved) and saving as draft (editor-rejected).
@@ -83,14 +81,14 @@ class PRAutoBlogger_Publisher {
$clean_content = PRAutoBlogger_Post_Assembler::sanitize_llm_content( $content );
$linked_content = PRAutoBlogger_Peptide_Linker::inject_links( $clean_content );
$post_data = array(
$post_data = [
'post_title' => sanitize_text_field( $idea->get_suggested_title() ),
'post_content' => wp_kses_post( $linked_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 ),
);
'meta_input' => $this->build_meta( $idea, $review ),
];
/** @see class-prautoblogger.php — listeners registered in main loader. */
$post_data = apply_filters( 'prautoblogger_filter_post_data', $post_data, $idea, $review );
@@ -122,22 +120,15 @@ 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 = array(
return [
'_prautoblogger_generated' => '1',
'_prautoblogger_analysis_id' => $idea->get_analysis_id(),
'_prautoblogger_source_ids' => wp_json_encode( $idea->get_source_ids() ),
@@ -151,10 +142,6 @@ class PRAutoBlogger_Publisher {
'_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;
}
return $meta;
];
}
}

View File

@@ -1,191 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* 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 = array(
'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( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"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, array( '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, array( '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(
array(
'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( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"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 ) : array();
}
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Semantic deduplication using embedding cosine similarity.
*
* Embeds candidate topics and recent post titles in a single batch API call,
@@ -26,13 +24,13 @@ class PRAutoBlogger_Semantic_Dedup {
private const SIMILARITY_THRESHOLD = 0.72;
/** @var float[][] Cached embeddings for recent post titles, keyed by index. */
private array $title_embeddings = array();
private array $title_embeddings = [];
/** @var string[] Recent post titles. */
private array $recent_titles = array();
private array $recent_titles = [];
/** @var float[][] Cached embeddings for accepted ideas, accumulated per-batch. */
private array $accepted_embeddings = array();
private array $accepted_embeddings = [];
/** @var bool Whether embeddings are available (false after API failure). */
private bool $embeddings_available = true;
@@ -160,7 +158,7 @@ class PRAutoBlogger_Semantic_Dedup {
if ( null === $this->provider ) {
$this->provider = new PRAutoBlogger_OpenRouter_Embedding_Provider();
}
$result = $this->provider->get_embeddings( array( $text ) );
$result = $this->provider->get_embeddings( [ $text ] );
return $result[0] ?? null;
} catch ( \Throwable $e ) {
// Degrade gracefully — one failed embed shouldn't kill the pipeline.
@@ -222,20 +220,18 @@ class PRAutoBlogger_Semantic_Dedup {
* @return string[]
*/
private function fetch_recent_post_titles(): array {
$query = new \WP_Query(
array(
'post_type' => 'post',
'post_status' => array( 'publish', 'draft', 'pending' ),
'meta_key' => '_prautoblogger_generated',
'meta_value' => '1',
'date_query' => array( array( 'after' => '30 days ago' ) ),
'posts_per_page' => 100,
'fields' => 'ids',
'no_found_rows' => true,
)
);
$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 = array();
$titles = [];
foreach ( $query->posts as $post_id ) {
$titles[] = get_the_title( $post_id );
}
@@ -249,122 +245,27 @@ class PRAutoBlogger_Semantic_Dedup {
* @return string[] Unique lowercase keywords.
*/
private function extract_meaningful_keywords( string $text ): array {
static $stopwords = array(
'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',
);
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 );
}
);
$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

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Orchestrates data collection from all enabled source providers.
*
* Iterates through enabled sources (Reddit, LLM Research), calls their
@@ -53,7 +51,7 @@ class PRAutoBlogger_Source_Collector {
// Only fall back to reddit if the option is corrupt (non-JSON).
// An empty array '[]' is a valid choice — the user disabled all sources.
if ( ! is_array( $enabled ) ) {
$enabled = array( 'reddit' );
$enabled = [ 'reddit' ];
}
$total_inserted = 0;
@@ -66,9 +64,9 @@ 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;
$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(
@@ -108,7 +106,7 @@ class PRAutoBlogger_Source_Collector {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$row = $wpdb->get_row(
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare(
"SELECT title, content, subreddit FROM {$table} WHERE id IN ({$placeholders}) ORDER BY score DESC LIMIT 1",
...$ids
),
@@ -119,10 +117,10 @@ class PRAutoBlogger_Source_Collector {
return null;
}
return array(
return [
'title' => $row['title'] ?? 'Reddit Discussion',
'selftext' => $row['content'] ?? '',
);
];
}
/**
@@ -152,7 +150,7 @@ class PRAutoBlogger_Source_Collector {
// the analyzer's 24-hour window picks them up again.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->query(
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare(
"INSERT 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)
@@ -177,7 +175,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 ) {
++$inserted;
$inserted++;
}
}
@@ -192,10 +190,10 @@ class PRAutoBlogger_Source_Collector {
* @return PRAutoBlogger_Source_Provider_Interface|null
*/
private function get_provider( string $source_type ): ?PRAutoBlogger_Source_Provider_Interface {
$providers = array(
$providers = [
'reddit' => PRAutoBlogger_Reddit_Provider::class,
'llm_research' => PRAutoBlogger_LLM_Research_Provider::class,
);
];
/**
* Filter the registered source providers.
@@ -232,20 +230,20 @@ class PRAutoBlogger_Source_Collector {
*/
private function get_config_for_source( string $source_type ): array {
if ( 'reddit' === $source_type ) {
return array(
'subreddits' => json_decode( get_option( 'prautoblogger_target_subreddits', '[]' ), true ) ?? array(),
return [
'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 = array(
$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 ) {
@@ -254,6 +252,6 @@ class PRAutoBlogger_Source_Collector {
return $config;
}
return array();
return [];
}
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Injects frontend typography and table styles for PRAutoBlogger-generated posts.
*
* What: Outputs inline CSS on all single post pages, applying
@@ -65,7 +63,7 @@ class PRAutoBlogger_Article_Typography {
* @return string CSS rules, or empty string if all defaults.
*/
private function build_css(): string {
$rules = array();
$rules = [];
$font_family = $this->get_font_family_stack();
if ( '' !== $font_family ) {
@@ -86,66 +84,60 @@ class PRAutoBlogger_Article_Typography {
// 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",
array(
'.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;",
'}',
)
);
$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",
array(
'.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}",
'}',
)
);
$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 );
@@ -161,16 +153,16 @@ class PRAutoBlogger_Article_Typography {
private function get_font_family_stack(): string {
$key = sanitize_text_field( get_option( self::OPT_FONT_FAMILY, 'default' ) );
$stacks = array(
'default' => '',
'inter' => '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
'georgia' => '"Georgia", "Times New Roman", Times, serif',
$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',
);
'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 ] ?? '';
}
@@ -181,7 +173,7 @@ class PRAutoBlogger_Article_Typography {
* @return array<string, string> Map of key => display label.
*/
public static function get_font_choices(): array {
return array(
return [
'default' => __( 'Theme Default (Inter)', 'prautoblogger' ),
'inter' => __( 'Inter (Sans-serif)', 'prautoblogger' ),
'georgia' => __( 'Georgia (Serif)', 'prautoblogger' ),
@@ -190,7 +182,7 @@ class PRAutoBlogger_Article_Typography {
'open_sans' => __( 'Open Sans (Sans-serif)', 'prautoblogger' ),
'roboto' => __( 'Roboto (Sans-serif)', 'prautoblogger' ),
'system' => __( 'System Default', 'prautoblogger' ),
);
];
}
/**
@@ -210,18 +202,18 @@ class PRAutoBlogger_Article_Typography {
$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 = array(
$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 ],
array(),
[],
null // No version for external CDN URLs.
);
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Registers the [prautoblogger_posts] shortcode and its supporting REST API endpoint.
*
* This is PRAutoBlogger's first public-facing frontend feature. It renders a React-powered
@@ -45,7 +43,7 @@ class PRAutoBlogger_Posts_Widget {
* @return void
*/
public function on_register_shortcode(): void {
add_shortcode( 'prautoblogger_posts', array( $this, 'render_shortcode' ) );
add_shortcode( 'prautoblogger_posts', [ $this, 'render_shortcode' ] );
}
/**
@@ -61,9 +59,9 @@ class PRAutoBlogger_Posts_Widget {
register_rest_route(
self::REST_NAMESPACE,
self::REST_ROUTE,
array(
[
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'handle_rest_request' ),
'callback' => [ $this, 'handle_rest_request' ],
'permission_callback' => function () {
/**
* Filter whether the posts widget REST endpoint is publicly accessible.
@@ -75,21 +73,21 @@ class PRAutoBlogger_Posts_Widget {
*/
return (bool) apply_filters( 'prautoblogger_rest_posts_public', true );
},
'args' => array(
'per_page' => array(
'args' => [
'per_page' => [
'type' => 'integer',
'default' => 6,
'minimum' => 1,
'maximum' => self::MAX_POSTS_PER_REQUEST,
'sanitize_callback' => 'absint',
),
'category' => array(
],
'category' => [
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_text_field',
),
),
)
],
],
]
);
}
@@ -111,12 +109,12 @@ class PRAutoBlogger_Posts_Widget {
*/
public function render_shortcode( $atts ): string {
$atts = shortcode_atts(
array(
[
'count' => '6',
'category' => '',
'title' => __( 'Latest Research & Insights', 'prautoblogger' ),
'subtitle' => __( 'Evidence-based articles on peptides, protocols, and emerging research', 'prautoblogger' ),
),
],
$atts,
'prautoblogger_posts'
);
@@ -150,14 +148,14 @@ class PRAutoBlogger_Posts_Widget {
wp_enqueue_style(
'prautoblogger-posts-widget',
PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/posts-widget.css',
array(),
[],
$version
);
wp_enqueue_script(
'prautoblogger-posts-widget',
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/posts-widget.js',
array( 'wp-element' ), // WordPress-bundled React.
[ 'wp-element' ], // WordPress-bundled React.
$version,
true // Load in footer for performance.
);
@@ -165,14 +163,14 @@ class PRAutoBlogger_Posts_Widget {
wp_localize_script(
'prautoblogger-posts-widget',
'prabPostsWidget',
array(
[
'restUrl' => esc_url_raw( rest_url( self::REST_NAMESPACE . self::REST_ROUTE ) ),
'count' => $count,
'category' => sanitize_text_field( $atts['category'] ),
'title' => sanitize_text_field( $atts['title'] ),
'subtitle' => sanitize_text_field( $atts['subtitle'] ),
'archiveUrl' => esc_url( get_post_type_archive_link( 'post' ) ?? '' ),
)
'archiveUrl' => esc_url( get_post_type_archive_link( 'post' ) ?: '' ),
]
);
}
@@ -193,19 +191,19 @@ class PRAutoBlogger_Posts_Widget {
}
$category = sanitize_text_field( $request->get_param( 'category' ) ?? '' );
$query_args = array(
$query_args = [
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => $per_page,
'orderby' => 'date',
'order' => 'DESC',
'meta_query' => array(
array(
'meta_query' => [
[
'key' => '_prautoblogger_generated',
'value' => '1',
),
),
);
],
],
];
// Optional category filter.
if ( ! empty( $category ) ) {
@@ -213,7 +211,7 @@ class PRAutoBlogger_Posts_Widget {
}
$query = new \WP_Query( $query_args );
$posts = array();
$posts = [];
foreach ( $query->posts as $post ) {
$posts[] = $this->format_post_for_response( $post );
@@ -251,7 +249,7 @@ class PRAutoBlogger_Posts_Widget {
$title = html_entity_decode( get_the_title( $post ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
$excerpt = html_entity_decode( get_the_excerpt( $post ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
return array(
return [
'id' => $post->ID,
'title' => $title,
'excerpt' => $excerpt,
@@ -259,8 +257,8 @@ class PRAutoBlogger_Posts_Widget {
'date' => get_the_date( 'c', $post ),
'category_name' => $category_name,
'category_slug' => $category_slug,
'featured_image' => $featured_image ?? '',
'featured_image' => $featured_image ?: '',
'word_count' => $word_count,
);
];
}
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Value object representing an analyzed topic pattern (recurring question,
* complaint, or comparison detected in source data).
*
@@ -45,7 +43,7 @@ class PRAutoBlogger_Analysis_Result {
$this->summary = $data['summary'] ?? null;
$this->frequency = (int) ( $data['frequency'] ?? 1 );
$this->relevance_score = (float) ( $data['relevance_score'] ?? 0.0 );
$this->source_ids = $data['source_ids'] ?? array();
$this->source_ids = $data['source_ids'] ?? [];
$this->analyzed_at = $data['analyzed_at'] ?? current_time( 'mysql' );
$this->metadata = $data['metadata'] ?? null;
}
@@ -90,7 +88,7 @@ class PRAutoBlogger_Analysis_Result {
* @return array<string, mixed>
*/
public function to_db_row(): array {
return array(
return [
'analysis_type' => $this->analysis_type,
'topic' => $this->topic,
'summary' => $this->summary,
@@ -99,6 +97,6 @@ class PRAutoBlogger_Analysis_Result {
'source_ids_json' => wp_json_encode( $this->source_ids ),
'analyzed_at' => $this->analyzed_at,
'metadata_json' => null !== $this->metadata ? wp_json_encode( $this->metadata ) : null,
);
];
}
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Value object representing a scored article idea ready for content generation.
*
* Created by the Idea_Scorer from Analysis_Results. Consumed by Content_Generator.
@@ -40,15 +38,15 @@ class PRAutoBlogger_Article_Idea {
* } $data
*/
public function __construct( array $data ) {
$this->topic = $data['topic'];
$this->article_type = $data['article_type'];
$this->suggested_title = $data['suggested_title'];
$this->summary = $data['summary'];
$this->score = (float) ( $data['score'] ?? 0.0 );
$this->analysis_id = (int) ( $data['analysis_id'] ?? 0 );
$this->source_ids = $data['source_ids'] ?? array();
$this->key_points = $data['key_points'] ?? array();
$this->target_keywords = $data['target_keywords'] ?? array();
$this->topic = $data['topic'];
$this->article_type = $data['article_type'];
$this->suggested_title = $data['suggested_title'];
$this->summary = $data['summary'];
$this->score = (float) ( $data['score'] ?? 0.0 );
$this->analysis_id = (int) ( $data['analysis_id'] ?? 0 );
$this->source_ids = $data['source_ids'] ?? [];
$this->key_points = $data['key_points'] ?? [];
$this->target_keywords = $data['target_keywords'] ?? [];
}
public function get_topic(): string {
@@ -95,7 +93,7 @@ class PRAutoBlogger_Article_Idea {
* @return array<string, mixed> Constructor-compatible array.
*/
public function to_array(): array {
return array(
return [
'topic' => $this->topic,
'article_type' => $this->article_type,
'suggested_title' => $this->suggested_title,
@@ -105,6 +103,6 @@ class PRAutoBlogger_Article_Idea {
'source_ids' => $this->source_ids,
'key_points' => $this->key_points,
'target_keywords' => $this->target_keywords,
);
];
}
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Value object encapsulating all parameters for a content generation request.
*
* Bundles the article idea with user-configured generation preferences
@@ -32,7 +30,7 @@ class PRAutoBlogger_Content_Request {
int $min_word_count,
int $max_word_count,
string $niche_description,
array $topic_exclusions = array(),
array $topic_exclusions = [],
string $writing_instructions = ''
) {
$this->idea = $idea;

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Value object for a post's composite performance score.
*
* Triggered by: Metrics_Collector creates these; Content_Analyzer reads them for self-improvement.
@@ -39,30 +37,21 @@ class PRAutoBlogger_Content_Score {
$this->measured_at = $data['measured_at'] ?? current_time( 'mysql' );
}
public function get_id(): int {
return $this->id; }
public function get_post_id(): int {
return $this->post_id; }
public function get_pageviews(): int {
return $this->pageviews; }
public function get_avg_time_on_page(): float {
return $this->avg_time_on_page; }
public function get_bounce_rate(): float {
return $this->bounce_rate; }
public function get_comment_count(): int {
return $this->comment_count; }
public function get_composite_score(): float {
return $this->composite_score; }
public function get_score_factors(): ?array {
return $this->score_factors; }
public function get_measured_at(): string {
return $this->measured_at; }
public function get_id(): int { return $this->id; }
public function get_post_id(): int { return $this->post_id; }
public function get_pageviews(): int { return $this->pageviews; }
public function get_avg_time_on_page(): float { return $this->avg_time_on_page; }
public function get_bounce_rate(): float { return $this->bounce_rate; }
public function get_comment_count(): int { return $this->comment_count; }
public function get_composite_score(): float { return $this->composite_score; }
public function get_score_factors(): ?array { return $this->score_factors; }
public function get_measured_at(): string { return $this->measured_at; }
/**
* @return array<string, mixed>
*/
public function to_db_row(): array {
return array(
return [
'post_id' => $this->post_id,
'pageviews' => $this->pageviews,
'avg_time_on_page' => $this->avg_time_on_page,
@@ -71,6 +60,6 @@ class PRAutoBlogger_Content_Score {
'composite_score' => $this->composite_score,
'score_factors_json' => null !== $this->score_factors ? wp_json_encode( $this->score_factors ) : null,
'measured_at' => $this->measured_at,
);
];
}
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Value object representing the chief editor agent's review of generated content.
*
* Triggered by: Chief_Editor creates these; Publisher consumes them.
@@ -32,12 +30,12 @@ class PRAutoBlogger_Editorial_Review {
* } $data
*/
public function __construct( array $data ) {
$this->verdict = $data['verdict']; // 'approved', 'revised', 'rejected'
$this->notes = $data['notes'];
$this->revised_content = $data['revised_content'] ?? null;
$this->quality_score = (float) ( $data['quality_score'] ?? 0.0 );
$this->seo_score = (float) ( $data['seo_score'] ?? 0.0 );
$this->issues = $data['issues'] ?? array();
$this->verdict = $data['verdict']; // 'approved', 'revised', 'rejected'
$this->notes = $data['notes'];
$this->revised_content = $data['revised_content'] ?? null;
$this->quality_score = (float) ( $data['quality_score'] ?? 0.0 );
$this->seo_score = (float) ( $data['seo_score'] ?? 0.0 );
$this->issues = $data['issues'] ?? [];
}
public function get_verdict(): string {

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Value object for an API call log entry (cost tracking).
*
* Triggered by: Cost_Tracker creates these; Metrics_Page displays them.
@@ -47,38 +45,25 @@ class PRAutoBlogger_Generation_Log {
$this->created_at = $data['created_at'] ?? current_time( 'mysql' );
}
public function get_id(): int {
return $this->id; }
public function get_post_id(): ?int {
return $this->post_id; }
public function get_run_id(): ?string {
return $this->run_id; }
public function get_stage(): string {
return $this->stage; }
public function get_provider(): string {
return $this->provider; }
public function get_model(): string {
return $this->model; }
public function get_prompt_tokens(): int {
return $this->prompt_tokens; }
public function get_completion_tokens(): int {
return $this->completion_tokens; }
public function get_estimated_cost(): float {
return $this->estimated_cost; }
public function get_request_json(): ?string {
return $this->request_json; }
public function get_response_status(): string {
return $this->response_status; }
public function get_error_message(): ?string {
return $this->error_message; }
public function get_created_at(): string {
return $this->created_at; }
public function get_id(): int { return $this->id; }
public function get_post_id(): ?int { return $this->post_id; }
public function get_run_id(): ?string { return $this->run_id; }
public function get_stage(): string { return $this->stage; }
public function get_provider(): string { return $this->provider; }
public function get_model(): string { return $this->model; }
public function get_prompt_tokens(): int { return $this->prompt_tokens; }
public function get_completion_tokens(): int { return $this->completion_tokens; }
public function get_estimated_cost(): float { return $this->estimated_cost; }
public function get_request_json(): ?string { return $this->request_json; }
public function get_response_status(): string { return $this->response_status; }
public function get_error_message(): ?string { return $this->error_message; }
public function get_created_at(): string { return $this->created_at; }
/**
* @return array<string, mixed>
*/
public function to_db_row(): array {
return array(
return [
'post_id' => $this->post_id,
'run_id' => $this->run_id,
'stage' => $this->stage,
@@ -91,6 +76,6 @@ class PRAutoBlogger_Generation_Log {
'response_status' => $this->response_status,
'error_message' => $this->error_message,
'created_at' => $this->created_at,
);
];
}
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Value object representing a single piece of collected social media data
* (e.g., a Reddit post or comment).
*
@@ -113,7 +111,7 @@ class PRAutoBlogger_Source_Data {
* @return array<string, mixed>
*/
public function to_db_row(): array {
return array(
return [
'source_type' => $this->source_type,
'source_id' => $this->source_id,
'subreddit' => $this->subreddit,
@@ -125,6 +123,6 @@ class PRAutoBlogger_Source_Data {
'permalink' => $this->permalink,
'collected_at' => $this->collected_at,
'metadata_json' => null !== $this->metadata ? wp_json_encode( $this->metadata ) : null,
);
];
}
}

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 reprices.
*/
private const COST_PER_MP_SCHNELL_USD = 0.0011;
/**
* Short-alias → fully-qualified Workers AI model id.
*
* FLUX.1 [dev] was removed from Cloudflare Workers AI in April 2026
* (returns HTTP 400 "No route for that URI"). Only schnell remains.
* Any saved setting referencing flux-1-dev falls through to schnell
* via the null-coalesce in resolve_model().
*
* @var array<string, string>
*/
private const MODEL_ALIAS_MAP = [
'flux-1-schnell' => '@cf/black-forest-labs/flux-1-schnell',
];
/**
* 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. 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;
return round( $mp * $rate, 6 );
}
}

View File

@@ -0,0 +1,296 @@
<?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 {
// FLUX.1 requires dimensions divisible by 8; round to nearest valid value.
$width = (int) ( round( $width / 8 ) * 8 );
$height = (int) ( round( $height / 8 ) * 8 );
$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 );
$this->support->throw_if_nsfw( $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 )
)
);
}
/**
* Sequential fallback — Cloudflare Workers AI does not support batching.
*
* {@inheritDoc}
*/
public function generate_image_batch( array $requests ): array {
$results = [];
foreach ( $requests as $key => $req ) {
try {
$results[ $key ] = $this->generate_image( $req['prompt'], $req['width'], $req['height'], $req['options'] ?? [] );
} catch ( PRAutoBlogger_Image_NSFW_Blocked $e ) {
// Tag NSFW so the pipeline's retry layer can rebuild the prompt.
$results[ $key ] = [ 'error' => $e->getMessage(), 'error_type' => 'nsfw_blocked' ];
} catch ( \Throwable $e ) {
$results[ $key ] = [ 'error' => $e->getMessage() ];
}
}
return $results;
}
/**
* 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,172 @@
<?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 {
$stored = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' );
if ( '' === $stored ) {
return '';
}
// If the token is already encrypted (has "enc:" prefix), decrypt it.
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
return (string) PRAutoBlogger_Encryption::decrypt( $stored );
}
// Legacy: token was saved as plaintext before the field was added to
// the encrypted list in sanitize_field(). Encrypt it now so future
// reads go through the normal path, then return the plaintext value.
$cipher = PRAutoBlogger_Encryption::encrypt( $stored );
if ( '' !== $cipher ) {
update_option( 'prautoblogger_cloudflare_ai_token', $cipher );
}
return $stored;
}
/**
* 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'
);
}
/**
* Cloudflare Workers AI error code for NSFW content rejection.
*
* Observed on FLUX.1 schnell on 2026-04-20 when backfilling the post
* "Semaglutide vs Tirzepatide". Documented upstream as
* `AiError: Input prompt contains NSFW content.`
*/
public const ERROR_CODE_NSFW = 3030;
/**
* Return true when the Cloudflare response body contains the NSFW
* content-filter error code (3030).
*
* The body shape is `{"errors":[{"code":3030,"message":"..."}],...}`.
* Parsing is tolerant: any JSON decode failure or missing key returns
* false, so a malformed 4xx never masquerades as NSFW.
*
* @param string $raw Raw response body from Cloudflare.
* @return bool True when at least one error entry carries code 3030.
*/
public function is_nsfw_error( string $raw ): bool {
$decoded = json_decode( $raw, true );
if ( ! is_array( $decoded ) || empty( $decoded['errors'] ) || ! is_array( $decoded['errors'] ) ) {
return false;
}
foreach ( $decoded['errors'] as $err ) {
if ( is_array( $err ) && (int) ( $err['code'] ?? 0 ) === self::ERROR_CODE_NSFW ) {
return true;
}
}
return false;
}
/**
* Throw a typed NSFW exception when the response body signals CF's
* content-filter rejection (code 3030). No-op otherwise.
*
* Centralised so callers stay at one line at the call site and so the
* exception-construction shape can't drift between call sites.
*
* @param string $raw Raw response body.
* @return void
* @throws PRAutoBlogger_Image_NSFW_Blocked When the body contains code 3030.
*/
public function throw_if_nsfw( string $raw ): void {
if ( ! $this->is_nsfw_error( $raw ) ) {
return;
}
throw new PRAutoBlogger_Image_NSFW_Blocked(
sprintf(
/* translators: %s: truncated error body. */
esc_html__( 'Cloudflare Workers AI rejected the prompt as NSFW: %s', 'prautoblogger' ),
esc_html( substr( $raw, 0, 300 ) )
),
$raw
);
}
}

View File

@@ -0,0 +1,122 @@
<?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' )
);
}
$stored = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' );
$api_token = '';
if ( '' !== $stored ) {
// Handle legacy plaintext tokens (saved before the field was in the encrypted list).
$api_token = PRAutoBlogger_Encryption::is_encrypted( $stored )
? (string) PRAutoBlogger_Encryption::decrypt( $stored )
: $stored;
}
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

@@ -2,22 +2,20 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* 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.
* Cloudflare Workers AI returns HTTP 400 with an `errors[].code === 3030`
* body when its content filter blocks a prompt. We catch that shape in
* the 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.
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider on NSFW 4xx.
* 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.
* @see providers/class-cloudflare-image-provider.php — Raises this.
* @see core/class-image-nsfw-retry.php — Catches + rebuilds prompt.
* @see https://developers.cloudflare.com/workers-ai/models/flux-1-schnell/ — Upstream error codes.
*/
class PRAutoBlogger_Image_NSFW_Blocked extends \RuntimeException {

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* LLM Deep Research source provider — uses a reasoning-capable model to
* identify trending topics, emerging questions, and content gaps in the niche.
*
@@ -47,7 +45,7 @@ class PRAutoBlogger_LLM_Research_Provider implements PRAutoBlogger_Source_Provid
if ( '' === trim( $prompt ) ) {
PRAutoBlogger_Logger::instance()->warning( 'LLM Research prompt is empty. Skipping.', 'llm-research' );
return array();
return [];
}
$system_prompt = $this->build_system_prompt();
@@ -55,22 +53,16 @@ class PRAutoBlogger_LLM_Research_Provider implements PRAutoBlogger_Source_Provid
$llm = new PRAutoBlogger_OpenRouter_Provider();
$response = $llm->send_chat_completion(
array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => $user_prompt,
),
),
[
[ 'role' => 'system', 'content' => $system_prompt ],
[ 'role' => 'user', 'content' => $user_prompt ],
],
$model,
array(
[
'temperature' => 0.7,
'max_tokens' => 8000,
'response_format' => array( 'type' => 'json_object' ),
)
'response_format' => [ 'type' => 'json_object' ],
]
);
// Use the pipeline's cost tracker if provided (for run_id tagging),
@@ -110,11 +102,11 @@ class PRAutoBlogger_LLM_Research_Provider implements PRAutoBlogger_Source_Provid
* @return array{remaining: int, limit: int, resets_at: string}
*/
public function get_rate_limit_status(): array {
return array(
return [
'remaining' => 999,
'limit' => 999,
'resets_at' => '',
);
'resets_at' => '',
];
}
/**
@@ -141,7 +133,7 @@ class PRAutoBlogger_LLM_Research_Provider implements PRAutoBlogger_Source_Provid
. "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.';
. "Generate 8-12 findings. Prioritize depth over breadth. Avoid generic or surface-level topics.";
}
/**
@@ -172,16 +164,16 @@ class PRAutoBlogger_LLM_Research_Provider implements PRAutoBlogger_Source_Provid
. mb_substr( $content, 0, 500 ),
'llm-research'
);
return array();
return [];
}
$items = array();
$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 ) ) );
$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 ) {
@@ -192,25 +184,23 @@ class PRAutoBlogger_LLM_Research_Provider implements PRAutoBlogger_Source_Provid
// but a new day produces fresh entries.
$source_id = 'llm_' . $run_date . '_' . $index;
$items[] = new PRAutoBlogger_Source_Data(
array(
'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' => array(
'category' => $category,
'data_source' => 'llm_research',
'model' => $model,
),
)
);
$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(

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Configuration helpers for OpenRouter provider.
*
* Resolves admin-configured settings for API endpoint selection

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Fetches text embeddings from OpenRouter for semantic similarity comparisons.
*
* Used by the Idea Scorer to detect semantically similar topics that keyword
@@ -43,7 +41,7 @@ class PRAutoBlogger_OpenRouter_Embedding_Provider {
*/
public function get_embeddings( array $texts, string $model = '' ): array {
if ( empty( $texts ) ) {
return array();
return [];
}
$api_key = $this->get_api_key();
@@ -66,16 +64,14 @@ class PRAutoBlogger_OpenRouter_Embedding_Provider {
try {
$response = wp_remote_post(
$base_url . '/embeddings',
array(
[
'timeout' => 30,
'headers' => $request_headers,
'body' => wp_json_encode(
array(
'model' => $model,
'input' => array_values( $texts ),
)
),
)
'body' => wp_json_encode( [
'model' => $model,
'input' => array_values( $texts ),
] ),
]
);
if ( is_wp_error( $response ) ) {
@@ -153,14 +149,11 @@ class PRAutoBlogger_OpenRouter_Embedding_Provider {
}
// 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 );
}
);
usort( $body['data'], static function ( array $a, array $b ): int {
return ( $a['index'] ?? 0 ) <=> ( $b['index'] ?? 0 );
} );
$embeddings = array();
$embeddings = [];
foreach ( $body['data'] as $item ) {
if ( ! isset( $item['embedding'] ) || ! is_array( $item['embedding'] ) ) {
throw new \RuntimeException( 'Embedding item missing "embedding" array.' );

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Parallel image generation via curl_multi for OpenRouter.
*
* Fires multiple chat/completions requests concurrently using PHP's
@@ -75,7 +73,7 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
*/
public function execute( array $requests ): array {
if ( empty( $requests ) ) {
return array();
return [];
}
$api_key = $this->support->get_api_key();
@@ -88,15 +86,15 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
);
// Convert associative headers to curl-style indexed array.
$curl_headers = 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 = array();
$start_times = array();
$handles = [];
$start_times = [];
foreach ( $requests as $key => $req ) {
$model = $this->pricing->resolve_model( (string) ( $req['options']['model'] ?? '' ) );
@@ -121,11 +119,7 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_add_handle
curl_multi_add_handle( $multi_handle, $ch );
$handles[ $key ] = array(
'handle' => $ch,
'model' => $model,
'req' => $req,
);
$handles[ $key ] = [ 'handle' => $ch, 'model' => $model, 'req' => $req ];
$start_times[ $key ] = microtime( true );
}
@@ -133,7 +127,7 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
$this->run_multi( $multi_handle );
// Collect results.
$results = array();
$results = [];
foreach ( $handles as $key => $entry ) {
$results[ $key ] = $this->collect_result(
(string) $key,
@@ -206,7 +200,7 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
$detail
);
PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' );
return array( 'error' => $msg );
return [ 'error' => $msg ];
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_getinfo
@@ -222,7 +216,7 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
substr( $raw, 0, 300 )
);
PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' );
return array( 'error' => $msg );
return [ 'error' => $msg ];
}
try {
@@ -234,10 +228,10 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
$e->getMessage()
);
PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' );
return array( 'error' => $msg );
return [ 'error' => $msg ];
}
return array(
return [
'bytes' => $image_bytes,
'mime_type' => 'image/png',
'width' => $req['width'],
@@ -246,7 +240,7 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
'seed' => null,
'cost_usd' => $this->pricing->estimate_cost( $req['width'], $req['height'], $model ),
'latency_ms' => $latency_ms,
);
];
}
/**
@@ -265,45 +259,17 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
$target = $height > 0 ? (float) $width / (float) $height : 1.0;
// Snap to nearest standard aspect ratio.
$aspects = array(
array(
'w' => 1,
'h' => 1,
'r' => 1.0,
),
array(
'w' => 3,
'h' => 2,
'r' => 1.5,
),
array(
'w' => 2,
'h' => 3,
'r' => 0.6667,
),
array(
'w' => 4,
'h' => 3,
'r' => 1.3333,
),
array(
'w' => 3,
'h' => 4,
'r' => 0.75,
),
array(
'w' => 16,
'h' => 9,
'r' => 1.7778,
),
array(
'w' => 9,
'h' => 16,
'r' => 0.5625,
),
);
$best = $aspects[0];
$best_d = abs( $target - $best['r'] );
$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 ) {
@@ -313,16 +279,16 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
}
$aspect = $best['w'] . ':' . $best['h'];
return array(
return [
'model' => $model,
'messages' => array(
array(
'messages' => [
[
'role' => 'user',
'content' => 'Generate an image: ' . $prompt,
),
),
'modalities' => array( 'image', 'text' ),
'image_config' => array( 'aspect_ratio' => $aspect ),
);
],
],
'modalities' => [ 'image', 'text' ],
'image_config' => [ 'aspect_ratio' => $aspect ],
];
}
}

View File

@@ -2,11 +2,9 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Pricing + model-id resolution helpers for the OpenRouter image provider.
*
* Mirrors the Runware pricing helper pattern. Each model's cost per image
* Mirrors the Cloudflare 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()
@@ -14,7 +12,7 @@ declare(strict_types=1);
* Dependencies: None — pure calculation + option reads.
*
* @see class-openrouter-image-provider.php — Caller.
* @see class-runware-image-pricing.php — Sibling pattern for Runware.
* @see class-cloudflare-image-pricing.php — Sibling pattern for Cloudflare.
*/
class PRAutoBlogger_OpenRouter_Image_Pricing {
@@ -24,13 +22,13 @@ class PRAutoBlogger_OpenRouter_Image_Pricing {
*
* @var array<string, float>
*/
private const COST_PER_IMAGE = array(
'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,
);
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,

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* OpenRouter image provider — model-agnostic, works with any image model.
*
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation.
@@ -45,7 +43,7 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
*
* Retries 5xx/429 with exponential backoff; 4xx (except 429) fail immediately.
*/
public function generate_image( string $prompt, int $width, int $height, array $options = array() ): array {
public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array {
$this->assert_valid_dimensions( $width, $height );
$prompt = trim( $prompt );
if ( '' === $prompt ) {
@@ -68,7 +66,7 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
$curl_filter = $this->request_builder->register_curl_auth_filter(
$headers,
wp_parse_url( $base_url, PHP_URL_HOST ) ?? ''
wp_parse_url( $base_url, PHP_URL_HOST ) ?: ''
);
$started_at = microtime( true );
@@ -76,14 +74,11 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
try {
for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) {
$response = wp_remote_post(
$url,
array(
'timeout' => PRAUTOBLOGGER_API_TIMEOUT_SECONDS,
'headers' => $headers,
'body' => wp_json_encode( $body ),
)
);
$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();
@@ -121,7 +116,7 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
$image_bytes = $this->support->extract_image_bytes( $raw );
return array(
return [
'bytes' => $image_bytes,
'mime_type' => 'image/png',
'width' => $width,
@@ -130,7 +125,7 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
'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 );
@@ -181,7 +176,7 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
}
/** {@inheritDoc} */
public function estimate_cost( int $width, int $height, array $options = array() ): float {
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 );
}
@@ -196,41 +191,32 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
try {
$api_key = $this->support->get_api_key();
} catch ( \RuntimeException $e ) {
return array(
'status' => 'error',
'message' => $e->getMessage(),
);
return [ 'status' => 'error', 'message' => $e->getMessage() ];
}
$response = wp_remote_get(
'https://openrouter.ai/api/v1/auth/key',
array(
'timeout' => 15,
'headers' => array( 'Authorization' => 'Bearer ' . $api_key ),
)
);
$response = wp_remote_get( 'https://openrouter.ai/api/v1/auth/key', [
'timeout' => 15,
'headers' => [ 'Authorization' => 'Bearer ' . $api_key ],
] );
if ( is_wp_error( $response ) ) {
return array(
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 array(
'status' => 'ok',
'message' => __( 'OpenRouter API key valid.', 'prautoblogger' ),
);
return [ 'status' => 'ok', 'message' => __( 'OpenRouter API key valid.', 'prautoblogger' ) ];
}
return array(
return [
'status' => 'error',
'message' => sprintf( __( 'OpenRouter returned HTTP %d.', 'prautoblogger' ), $status ),
'debug' => substr( (string) wp_remote_retrieve_body( $response ), 0, 200 ),
);
];
}
/**
@@ -238,62 +224,34 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
*
* @var array<int, array{w: int, h: int, ratio: float}>
*/
private const STANDARD_ASPECTS = array(
array(
'w' => 1,
'h' => 1,
'ratio' => 1.0,
),
array(
'w' => 3,
'h' => 2,
'ratio' => 1.5,
),
array(
'w' => 2,
'h' => 3,
'ratio' => 0.6667,
),
array(
'w' => 4,
'h' => 3,
'ratio' => 1.3333,
),
array(
'w' => 3,
'h' => 4,
'ratio' => 0.75,
),
array(
'w' => 16,
'h' => 9,
'ratio' => 1.7778,
),
array(
'w' => 9,
'h' => 16,
'ratio' => 0.5625,
),
);
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 array(
'model' => $model,
'messages' => array(
array(
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' => array( 'image', 'text' ),
'image_config' => array(
'modalities' => [ 'image', 'text' ],
'image_config' => [
'aspect_ratio' => $aspect,
),
);
],
];
}
/**
@@ -304,9 +262,9 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
* @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'] );
$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'] );
@@ -334,10 +292,7 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
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
$width, $height, self::MIN_DIMENSION_PX, self::MAX_DIMENSION_PX
)
);
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Support helpers for the OpenRouter image provider — API key lookup,
* response parsing, retry backoff, and logging.
*
@@ -71,7 +69,7 @@ class PRAutoBlogger_OpenRouter_Image_Support {
);
}
$images = $decoded['choices'][0]['message']['images'] ?? array();
$images = $decoded['choices'][0]['message']['images'] ?? [];
if ( empty( $images ) ) {
throw new \RuntimeException(
esc_html__( 'OpenRouter response contained no images.', 'prautoblogger' )

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* OpenRouter model pricing and availability lookup.
*
* Manages pricing tables, model lists, and cost estimation. Decoupled from
@@ -23,36 +21,15 @@ class PRAutoBlogger_OpenRouter_Pricing {
*
* @var array<string, array{prompt: float, completion: float}>
*/
private const MODEL_PRICING = array(
'anthropic/claude-3.5-haiku' => array(
'prompt' => 0.80,
'completion' => 4.00,
),
'anthropic/claude-sonnet-4' => array(
'prompt' => 3.00,
'completion' => 15.00,
),
'anthropic/claude-opus-4' => array(
'prompt' => 15.00,
'completion' => 75.00,
),
'openai/gpt-4o' => array(
'prompt' => 2.50,
'completion' => 10.00,
),
'openai/gpt-4o-mini' => array(
'prompt' => 0.15,
'completion' => 0.60,
),
'google/gemini-2.0-flash' => array(
'prompt' => 0.10,
'completion' => 0.40,
),
'meta-llama/llama-3.3-70b' => array(
'prompt' => 0.40,
'completion' => 0.40,
),
);
private const MODEL_PRICING = [
'anthropic/claude-3.5-haiku' => [ 'prompt' => 0.80, 'completion' => 4.00 ],
'anthropic/claude-sonnet-4' => [ 'prompt' => 3.00, 'completion' => 15.00 ],
'anthropic/claude-opus-4' => [ 'prompt' => 15.00, 'completion' => 75.00 ],
'openai/gpt-4o' => [ 'prompt' => 2.50, 'completion' => 10.00 ],
'openai/gpt-4o-mini' => [ 'prompt' => 0.15, 'completion' => 0.60 ],
'google/gemini-2.0-flash' => [ 'prompt' => 0.10, 'completion' => 0.40 ],
'meta-llama/llama-3.3-70b' => [ 'prompt' => 0.40, 'completion' => 0.40 ],
];
/**
* Get available models from OpenRouter's /models endpoint.
@@ -71,7 +48,7 @@ class PRAutoBlogger_OpenRouter_Pricing {
$api_key = $this->get_api_key();
if ( '' === $api_key ) {
return array();
return [];
}
// Belt-and-suspenders: inject Authorization at cURL level (see OpenRouter_Provider).
@@ -81,47 +58,43 @@ class PRAutoBlogger_OpenRouter_Pricing {
return;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
curl_setopt(
$handle,
CURLOPT_HTTPHEADER,
array(
'Authorization: ' . $auth_header_value,
)
);
curl_setopt( $handle, CURLOPT_HTTPHEADER, [
'Authorization: ' . $auth_header_value,
] );
};
add_action( 'http_api_curl', $curl_auth_filter_mod, 99, 3 );
$response = wp_remote_get(
'https://openrouter.ai/api/v1/models',
array(
[
'timeout' => 30,
'headers' => array(
'headers' => [
'Authorization' => 'Bearer ' . $api_key,
),
)
],
]
);
remove_action( 'http_api_curl', $curl_auth_filter_mod, 99 );
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
PRAutoBlogger_Logger::instance()->warning( 'Failed to fetch OpenRouter models list.', 'openrouter' );
return array();
return [];
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
$models = array();
$models = [];
if ( isset( $data['data'] ) && is_array( $data['data'] ) ) {
foreach ( $data['data'] as $model ) {
$models[] = array(
$models[] = [
'id' => $model['id'] ?? '',
'name' => $model['name'] ?? $model['id'] ?? '',
'context_length' => (int) ( $model['context_length'] ?? 0 ),
'pricing' => array(
'pricing' => [
'prompt' => (float) ( $model['pricing']['prompt'] ?? 0 ) * 1000000,
'completion' => (float) ( $model['pricing']['completion'] ?? 0 ) * 1000000,
),
);
],
];
}
}
@@ -162,10 +135,7 @@ class PRAutoBlogger_OpenRouter_Pricing {
if ( null === $pricing ) {
// Unknown model — use a conservative estimate.
PRAutoBlogger_Logger::instance()->warning( "Unknown model pricing for '{$model}'. Using conservative estimate.", 'openrouter' );
$pricing = array(
'prompt' => 10.0,
'completion' => 30.0,
);
$pricing = [ 'prompt' => 10.0, 'completion' => 30.0 ];
}
return ( $prompt_tokens * $pricing['prompt'] / 1000000 )

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* OpenRouter API integration for all LLM calls (analysis, writing, editing).
*
* OpenRouter provides a unified API to access models from Anthropic, OpenAI,
@@ -52,7 +50,7 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
*
* @throws \RuntimeException On API error after retries exhausted.
*/
public function send_chat_completion( array $messages, string $model, array $options = array() ): array {
public function send_chat_completion( array $messages, string $model, array $options = [] ): array {
$api_key = $this->get_api_key();
if ( '' === $api_key ) {
throw new \RuntimeException(
@@ -76,10 +74,10 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
);
}
$body = array(
$body = [
'model' => $model,
'messages' => $messages,
);
];
if ( isset( $options['temperature'] ) ) {
$body['temperature'] = $options['temperature'];
@@ -95,10 +93,10 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
if ( isset( $options['reasoning'] ) ) {
$body['reasoning'] = $options['reasoning'];
} elseif ( '1' === get_option( 'prautoblogger_reasoning_enabled', '0' ) ) {
$body['reasoning'] = array(
$body['reasoning'] = [
'enabled' => true,
'effort' => get_option( 'prautoblogger_reasoning_effort', 'medium' ),
);
];
}
$last_error = '';
@@ -109,8 +107,8 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
$cache_ttl = $config->get_cache_ttl_seconds();
$via_gateway = $config->is_via_gateway();
$builder = new PRAutoBlogger_OpenRouter_Request_Builder();
$request_headers = $builder->build_headers( $api_key, $via_gateway, $cache_ttl );
$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 );
try {
@@ -119,11 +117,11 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) {
$response = wp_remote_post(
$base_url . '/chat/completions',
array(
[
'timeout' => PRAUTOBLOGGER_API_TIMEOUT_SECONDS,
'headers' => $request_headers,
'body' => wp_json_encode( $body ),
)
]
);
if ( is_wp_error( $response ) ) {

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Request builder for OpenRouter API calls.
*
* Encapsulates the logic for building HTTP request headers, including
@@ -31,12 +29,12 @@ class PRAutoBlogger_OpenRouter_Request_Builder {
* @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 = 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;
@@ -68,7 +66,7 @@ class PRAutoBlogger_OpenRouter_Request_Builder {
if ( '' === $base_host || false === strpos( (string) $url, $base_host ) ) {
return;
}
$curl_headers = array();
$curl_headers = [];
foreach ( $request_headers as $name => $value ) {
$curl_headers[] = $name . ': ' . $value;
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Response parser for OpenRouter API responses.
*
* Encapsulates the logic for parsing and validating OpenRouter chat completion
@@ -96,12 +94,12 @@ class PRAutoBlogger_OpenRouter_Response_Parser {
$content = '';
}
$usage = $data['usage'] ?? array();
$usage = $data['usage'] ?? [];
// Reasoning content lives alongside 'content' in the message object.
$reasoning_content = $data['choices'][0]['message']['reasoning'] ?? '';
return array(
return [
'content' => (string) $content,
'model' => $data['model'] ?? $model,
'prompt_tokens' => (int) ( $usage['prompt_tokens'] ?? 0 ),
@@ -110,7 +108,7 @@ class PRAutoBlogger_OpenRouter_Response_Parser {
'total_tokens' => (int) ( $usage['total_tokens'] ?? 0 ),
'finish_reason' => $data['choices'][0]['finish_reason'] ?? 'unknown',
'reasoning_content' => (string) $reasoning_content,
);
];
}
/**

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Credential + connectivity validator for the OpenRouter LLM provider.
*
* Split out of the provider to keep each file under the 300-line cap and
@@ -81,24 +79,20 @@ class PRAutoBlogger_OpenRouter_Validator {
return;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
curl_setopt(
$handle,
CURLOPT_HTTPHEADER,
array(
'Authorization: ' . $auth_header_value,
)
);
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',
array(
[
'timeout' => self::TIMEOUT_SECONDS,
'headers' => array(
'headers' => [
'Authorization' => 'Bearer ' . $api_key,
),
)
],
]
);
remove_action( 'http_api_curl', $curl_auth_filter_cred, 99 );
@@ -121,10 +115,10 @@ class PRAutoBlogger_OpenRouter_Validator {
$status_code = wp_remote_retrieve_response_code( $response );
if ( 200 === $status_code ) {
return array(
return [
'status' => 'ok',
'message' => __( 'OpenRouter connected.', 'prautoblogger' ),
);
];
}
$body_raw = wp_remote_retrieve_body( $response );
@@ -149,10 +143,10 @@ class PRAutoBlogger_OpenRouter_Validator {
* @return array{status: string, message: string, debug: string}
*/
private function err( string $debug, string $message ): array {
return array(
return [
'status' => 'error',
'message' => $message,
'debug' => $debug,
);
];
}
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Reddit endpoint availability checker.
*
* Probes Reddit's RSS and .json endpoints to determine which data sources
@@ -65,12 +63,12 @@ class PRAutoBlogger_Reddit_Availability {
$url = self::BASE_URL . '/r/all/hot.rss?limit=1';
$response = wp_remote_get(
$url,
array(
[
'timeout' => 10,
'headers' => array(
'headers' => [
'User-Agent' => self::USER_AGENT,
),
)
],
]
);
if ( is_wp_error( $response ) ) {
@@ -93,12 +91,12 @@ class PRAutoBlogger_Reddit_Availability {
$url = self::BASE_URL . '/r/all/hot.json?limit=1';
$response = wp_remote_get(
$url,
array(
[
'timeout' => 10,
'headers' => array(
'headers' => [
'User-Agent' => self::USER_AGENT,
),
)
],
]
);
if ( is_wp_error( $response ) ) {
@@ -123,11 +121,11 @@ class PRAutoBlogger_Reddit_Availability {
return $status;
}
return array(
return [
'remaining' => 10,
'limit' => 10,
'resets_at' => '',
);
];
}
/**
@@ -145,13 +143,13 @@ class PRAutoBlogger_Reddit_Availability {
public function api_get( string $url ): ?array {
$response = wp_remote_get(
$url,
array(
[
'timeout' => 30,
'headers' => array(
'headers' => [
'User-Agent' => self::USER_AGENT,
'Accept' => 'application/json',
),
)
],
]
);
if ( is_wp_error( $response ) ) {
@@ -170,11 +168,11 @@ class PRAutoBlogger_Reddit_Availability {
if ( '' !== $rate_remaining ) {
set_transient(
self::RATE_LIMIT_TRANSIENT,
array(
[
'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
);
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Reddit HTTP client — fetches posts via RSS (primary) and .json (fallback).
*
* RSS feeds are the primary data source because they work reliably from datacenter
@@ -99,13 +97,13 @@ class PRAutoBlogger_Reddit_JSON_Client {
$response = wp_remote_get(
$url,
array(
[
'timeout' => self::TIMEOUT_SECONDS,
'headers' => array(
'headers' => [
'User-Agent' => self::USER_AGENT,
'Accept' => 'application/atom+xml, application/rss+xml',
),
)
],
]
);
if ( is_wp_error( $response ) ) {
@@ -113,7 +111,7 @@ class PRAutoBlogger_Reddit_JSON_Client {
'Reddit RSS GET failed: ' . $response->get_error_message(),
'reddit'
);
return array();
return [];
}
$status_code = wp_remote_retrieve_response_code( $response );
@@ -122,10 +120,10 @@ class PRAutoBlogger_Reddit_JSON_Client {
sprintf( 'Reddit RSS HTTP %d for r/%s', $status_code, $subreddit ),
'reddit'
);
return array();
return [];
}
$body = wp_remote_retrieve_body( $response );
$body = wp_remote_retrieve_body( $response );
$parser = new PRAutoBlogger_Reddit_RSS_Parser();
return $parser->parse( $body, $subreddit );
}
@@ -156,15 +154,15 @@ class PRAutoBlogger_Reddit_JSON_Client {
$response = $this->api_get( $url );
if ( null === $response || ! isset( $response['data']['children'] ) ) {
return array();
return [];
}
$posts = array();
$posts = [];
foreach ( $response['data']['children'] as $child ) {
if ( isset( $child['data'] ) ) {
$post = $child['data'];
$post = $child['data'];
$post['data_source'] = 'reddit_json';
$posts[] = $post;
$posts[] = $post;
}
}
@@ -199,10 +197,10 @@ class PRAutoBlogger_Reddit_JSON_Client {
// Comments endpoint returns [post_listing, comments_listing].
if ( ! is_array( $response ) || ! isset( $response[1]['data']['children'] ) ) {
return array();
return [];
}
$comments = array();
$comments = [];
foreach ( $response[1]['data']['children'] as $child ) {
// Filter out "more" type entries, keep only actual comments (kind=t1).
if ( isset( $child['data'] ) && 't1' === ( $child['kind'] ?? '' ) ) {

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Reddit data provider — collects posts and comments from target subreddits.
*
* Uses Reddit RSS/Atom feeds as the PRIMARY data source (reliable from datacenter IPs,
@@ -49,7 +47,7 @@ class PRAutoBlogger_Reddit_Provider implements PRAutoBlogger_Source_Provider_Int
$comments_limit = $config['comments_per_post'] ?? 5;
$client = new PRAutoBlogger_Reddit_JSON_Client();
$collected = array();
$collected = [];
PRAutoBlogger_Logger::instance()->info(
sprintf(
@@ -74,28 +72,26 @@ class PRAutoBlogger_Reddit_Provider implements PRAutoBlogger_Source_Provider_Int
foreach ( $posts as $post ) {
$data_source = $post['data_source'] ?? 'reddit';
$collected[] = new PRAutoBlogger_Source_Data(
array(
'source_type' => 'reddit',
'source_id' => 't3_' . ( $post['id'] ?? '' ),
'subreddit' => $subreddit,
'title' => $post['title'] ?? '',
'content' => $post['selftext'] ?? '',
'author' => $post['author'] ?? '[deleted]',
'score' => (int) ( $post['score'] ?? 0 ),
'comment_count' => (int) ( $post['num_comments'] ?? 0 ),
'permalink' => 'https://reddit.com' . ( $post['permalink'] ?? '' ),
'collected_at' => current_time( 'mysql' ),
'metadata' => array(
'post_type' => ( $post['is_self'] ?? false ) ? 'self' : 'link',
'flair' => $post['link_flair_text'] ?? null,
'created_utc' => $post['created_utc'] ?? null,
'upvote_ratio' => $post['upvote_ratio'] ?? null,
'is_original' => $post['is_original_content'] ?? false,
'data_source' => $data_source,
),
)
);
$collected[] = new PRAutoBlogger_Source_Data( [
'source_type' => 'reddit',
'source_id' => 't3_' . ( $post['id'] ?? '' ),
'subreddit' => $subreddit,
'title' => $post['title'] ?? '',
'content' => $post['selftext'] ?? '',
'author' => $post['author'] ?? '[deleted]',
'score' => (int) ( $post['score'] ?? 0 ),
'comment_count' => (int) ( $post['num_comments'] ?? 0 ),
'permalink' => 'https://reddit.com' . ( $post['permalink'] ?? '' ),
'collected_at' => current_time( 'mysql' ),
'metadata' => [
'post_type' => ( $post['is_self'] ?? false ) ? 'self' : 'link',
'flair' => $post['link_flair_text'] ?? null,
'created_utc' => $post['created_utc'] ?? null,
'upvote_ratio' => $post['upvote_ratio'] ?? null,
'is_original' => $post['is_original_content'] ?? false,
'data_source' => $data_source,
],
] );
// Fetch comments for high-engagement posts via .json
// (comments are not available in RSS feeds).
@@ -104,27 +100,25 @@ class PRAutoBlogger_Reddit_Provider implements PRAutoBlogger_Source_Provider_Int
$comments = $client->fetch_comments( $subreddit, $post_id, $comments_limit );
foreach ( $comments as $comment ) {
$collected[] = new PRAutoBlogger_Source_Data(
array(
'source_type' => 'reddit',
'source_id' => 't1_' . ( $comment['id'] ?? '' ),
'subreddit' => $subreddit,
'title' => null,
'content' => $comment['body'] ?? '',
'author' => $comment['author'] ?? '[deleted]',
'score' => (int) ( $comment['score'] ?? 0 ),
'comment_count' => 0,
'permalink' => 'https://reddit.com' . ( $comment['permalink'] ?? '' ),
'collected_at' => current_time( 'mysql' ),
'metadata' => array(
'parent_post_id' => 't3_' . $post_id,
'parent_title' => $post['title'] ?? '',
'is_comment' => true,
'created_utc' => $comment['created_utc'] ?? null,
'data_source' => 'reddit_json',
),
)
);
$collected[] = new PRAutoBlogger_Source_Data( [
'source_type' => 'reddit',
'source_id' => 't1_' . ( $comment['id'] ?? '' ),
'subreddit' => $subreddit,
'title' => null,
'content' => $comment['body'] ?? '',
'author' => $comment['author'] ?? '[deleted]',
'score' => (int) ( $comment['score'] ?? 0 ),
'comment_count' => 0,
'permalink' => 'https://reddit.com' . ( $comment['permalink'] ?? '' ),
'collected_at' => current_time( 'mysql' ),
'metadata' => [
'parent_post_id' => 't3_' . $post_id,
'parent_title' => $post['title'] ?? '',
'is_comment' => true,
'created_utc' => $comment['created_utc'] ?? null,
'data_source' => 'reddit_json',
],
] );
}
}
}
@@ -190,6 +184,6 @@ class PRAutoBlogger_Reddit_Provider implements PRAutoBlogger_Source_Provider_Int
private function get_configured_subreddits(): array {
$json = get_option( 'prautoblogger_target_subreddits', '' );
$list = json_decode( $json, true );
return is_array( $list ) ? $list : array();
return is_array( $list ) ? $list : [];
}
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* 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
@@ -43,7 +41,7 @@ class PRAutoBlogger_Reddit_RSS_Parser {
if ( false === $feed ) {
PRAutoBlogger_Logger::instance()->error( 'Failed to parse Reddit RSS XML.', 'reddit' );
return array();
return [];
}
// Register the Atom namespace.
@@ -51,10 +49,10 @@ class PRAutoBlogger_Reddit_RSS_Parser {
$entries = $feed->xpath( '//atom:entry' );
if ( empty( $entries ) ) {
return array();
return [];
}
$posts = array();
$posts = [];
foreach ( $entries as $entry ) {
$post = $this->parse_entry( $entry, $subreddit );
if ( null !== $post ) {
@@ -120,7 +118,7 @@ class PRAutoBlogger_Reddit_RSS_Parser {
$permalink = $parsed['path'] ?? '';
}
return array(
return [
'id' => $post_id,
'title' => $title,
'selftext' => $content,
@@ -134,6 +132,6 @@ class PRAutoBlogger_Reddit_RSS_Parser {
'upvote_ratio' => null,
'is_original_content' => false,
'data_source' => 'reddit_rss',
);
];
}
}

View File

@@ -1,302 +0,0 @@
<?php
// phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
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 array();
}
$api_key = $this->support->get_api_key();
$round1 = $this->round_one_inference( $api_key, $requests );
$results = array();
$to_download = array();
foreach ( $round1 as $key => $entry ) {
if ( isset( $entry['error'] ) ) {
$results[ $key ] = array( '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 ] = array( 'error' => $bytes_map[ $key ]['error'] );
continue;
}
$results[ $key ] = array(
'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 = array( 'Content-Type: application/json' );
$mh = curl_multi_init();
$handles = array();
$start_times = array();
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 ] = array(
'handle' => $ch,
'model' => $model,
'width' => $snap_w,
'height' => $snap_h,
'seed' => $seed,
);
$start_times[ $key ] = microtime( true );
}
$this->run_multi( $mh );
$out = array();
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 = array();
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 = array();
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 ] = array( '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 ] = array( 'error' => $msg );
} else {
$out[ $key ] = array( '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 array(
array(
'taskType' => 'authentication',
'apiKey' => $api_key,
),
array(
'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 array( '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 array( '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 array( 'error' => $msg );
}
return array(
'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,120 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* 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 = array(
// 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 = array(
'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,314 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* 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() ): 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 = array( '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,
array(
'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 array(
'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 = array() ): 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 array(
'status' => 'error',
'message' => $e->getMessage(),
);
}
$body = array(
array(
'taskType' => 'authentication',
'apiKey' => $api_key,
),
);
$response = wp_remote_post(
PRAutoBlogger_Runware_Image_Support::API_URL,
array(
'timeout' => 15,
'headers' => array( 'Content-Type' => 'application/json' ),
'body' => wp_json_encode( $body ),
)
);
if ( is_wp_error( $response ) ) {
return array(
'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 array(
'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] ?? array();
$msg = is_array( $first ) ? (string) ( $first['message'] ?? '' ) : (string) $first;
return array(
'status' => 'error',
'message' => __( 'Runware rejected the API key.', 'prautoblogger' ),
'debug' => substr( $msg, 0, 200 ),
);
}
return array(
'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 array(
array(
'taskType' => 'authentication',
'apiKey' => $api_key,
),
array(
'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,224 +0,0 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* 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] ?? array();
$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'] ?? array();
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,
array(
'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 — Cloudflare Workers AI implementation.
* @see interface-llm-provider.php — Parallel pattern for LLM providers.
* @see ARCHITECTURE.md — External API Integrations table.
*/
@@ -61,7 +60,7 @@ interface PRAutoBlogger_Image_Provider_Interface {
* an invalid response shape.
* @throws \InvalidArgumentException If dimensions are out of range.
*/
public function generate_image( string $prompt, int $width, int $height, array $options = array() ): array;
public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array;
/**
* Estimate the USD cost of a single generation call before making it.
@@ -75,12 +74,12 @@ interface PRAutoBlogger_Image_Provider_Interface {
*
* @return float Estimated cost in USD for a single image at the given dimensions.
*/
public function estimate_cost( int $width, int $height, array $options = array() ): float;
public function estimate_cost( int $width, int $height, array $options = [] ): float;
/**
* 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;

View File

@@ -35,7 +35,7 @@ interface PRAutoBlogger_LLM_Provider_Interface {
*
* @throws \RuntimeException On API error after retries exhausted.
*/
public function send_chat_completion( array $messages, string $model, array $options = array() ): array;
public function send_chat_completion( array $messages, string $model, array $options = [] ): array;
/**
* Get the list of available models from the provider.

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Normalizes raw OpenRouter model data into the standardized record shape.
*
* Extracted from PRAutoBlogger_OpenRouter_Model_Registry so both classes stay
@@ -25,36 +23,15 @@ class PRAutoBlogger_OpenRouter_Model_Normalizer {
*
* @var array<string, array{in: string[], out: string[]}>
*/
private const CAPABILITY_MAP = array(
'text→text' => array(
'in' => array( 'text' ),
'out' => array( 'text' ),
),
'text+image→text' => array(
'in' => array( 'text', 'image' ),
'out' => array( 'text' ),
),
'text+audio→text' => array(
'in' => array( 'text', 'audio' ),
'out' => array( 'text' ),
),
'text→image' => array(
'in' => array( 'text' ),
'out' => array( 'image' ),
),
'text→audio' => array(
'in' => array( 'text' ),
'out' => array( 'audio' ),
),
'text→video' => array(
'in' => array( 'text' ),
'out' => array( 'video' ),
),
'text→embedding' => array(
'in' => array( 'text' ),
'out' => array( 'embedding' ),
),
);
private const CAPABILITY_MAP = [
'text→text' => [ 'in' => [ 'text' ], 'out' => [ 'text' ] ],
'text+image→text' => [ 'in' => [ 'text', 'image' ], 'out' => [ 'text' ] ],
'text+audio→text' => [ 'in' => [ 'text', 'audio' ], 'out' => [ 'text' ] ],
'text→image' => [ 'in' => [ 'text' ], 'out' => [ 'image' ] ],
'text→audio' => [ 'in' => [ 'text' ], 'out' => [ 'audio' ] ],
'text→video' => [ 'in' => [ 'text' ], 'out' => [ 'video' ] ],
'text→embedding' => [ 'in' => [ 'text' ], 'out' => [ 'embedding' ] ],
];
/**
* Normalize raw OpenRouter model records into the standardized shape.
@@ -77,15 +54,15 @@ class PRAutoBlogger_OpenRouter_Model_Normalizer {
* }> Normalized records, sorted by name ascending.
*/
public function normalize( array $raw_models ): array {
$normalized = array();
$normalized = [];
foreach ( $raw_models as $model ) {
if ( ! is_array( $model ) || ! isset( $model['id'] ) ) {
continue;
}
$input_modalities = (array) ( $model['architecture']['input_modalities'] ?? array( 'text' ) );
$output_modalities = (array) ( $model['architecture']['output_modalities'] ?? array( 'text' ) );
$input_modalities = (array) ( $model['architecture']['input_modalities'] ?? [ 'text' ] );
$output_modalities = (array) ( $model['architecture']['output_modalities'] ?? [ 'text' ] );
$name_parts = explode( '/', (string) $model['id'], 2 );
@@ -93,7 +70,7 @@ class PRAutoBlogger_OpenRouter_Model_Normalizer {
$prompt_price = (float) ( $model['pricing']['prompt'] ?? 0 );
$completion_price = (float) ( $model['pricing']['completion'] ?? 0 );
$normalized[] = array(
$normalized[] = [
'id' => (string) $model['id'],
'name' => (string) ( $model['name'] ?? $model['id'] ),
'provider' => $name_parts[0] ?? 'unknown',
@@ -103,15 +80,12 @@ class PRAutoBlogger_OpenRouter_Model_Normalizer {
'capabilities' => $this->derive_capabilities( $input_modalities, $output_modalities ),
'deprecated' => false,
'updated_at' => (int) ( $model['created'] ?? 0 ),
);
];
}
usort(
$normalized,
static function ( array $a, array $b ): int {
return strcasecmp( $a['name'], $b['name'] );
}
);
usort( $normalized, static function ( array $a, array $b ): int {
return strcasecmp( $a['name'], $b['name'] );
} );
return $normalized;
}
@@ -125,7 +99,7 @@ class PRAutoBlogger_OpenRouter_Model_Normalizer {
* @return string[] Matched capability labels. Defaults to ['text→text'].
*/
private function derive_capabilities( array $input_modalities, array $output_modalities ): array {
$caps = array();
$caps = [];
foreach ( self::CAPABILITY_MAP as $label => $requirements ) {
$input_match = empty( array_diff( $requirements['in'], $input_modalities ) );
@@ -135,6 +109,6 @@ class PRAutoBlogger_OpenRouter_Model_Normalizer {
}
}
return empty( $caps ) ? array( 'text→text' ) : $caps;
return empty( $caps ) ? [ 'text→text' ] : $caps;
}
}

View File

@@ -2,8 +2,6 @@
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* OpenRouter model registry — fetches, normalizes, and caches the model list.
*
* All config is constructor-injected. No PRAUTOBLOGGER_* constants referenced
@@ -54,11 +52,11 @@ class PRAutoBlogger_OpenRouter_Model_Registry implements PRAutoBlogger_Model_Reg
) {
$this->option_name = $option_name;
$this->fetched_at_option = $option_name . '_fetched_at';
$this->transient_name = $transient_name;
$this->endpoint = $endpoint;
$this->cache_ttl = $cache_ttl_seconds;
$this->transient_name = $transient_name;
$this->endpoint = $endpoint;
$this->cache_ttl = $cache_ttl_seconds;
$this->idempotency_window = $idempotency_seconds;
$this->normalizer = new PRAutoBlogger_OpenRouter_Model_Normalizer();
$this->normalizer = new PRAutoBlogger_OpenRouter_Model_Normalizer();
}
/** {@inheritDoc} */
@@ -72,7 +70,7 @@ class PRAutoBlogger_OpenRouter_Model_Registry implements PRAutoBlogger_Model_Reg
array_filter(
$this->load_payload(),
static function ( array $model ) use ( $capability ): bool {
return in_array( $capability, $model['capabilities'] ?? array(), true );
return in_array( $capability, $model['capabilities'] ?? [], true );
}
)
);
@@ -94,20 +92,14 @@ class PRAutoBlogger_OpenRouter_Model_Registry implements PRAutoBlogger_Model_Reg
if ( ! $force && $fetched_at > 0 && ( time() - $fetched_at ) < $this->idempotency_window ) {
$payload = $this->load_payload();
return array(
'count' => count( $payload ),
'fetched_at' => $fetched_at,
);
return [ 'count' => count( $payload ), 'fetched_at' => $fetched_at ];
}
$raw_models = $this->fetch_from_api();
if ( null === $raw_models ) {
$this->flag_stale();
$stale = $this->load_from_option();
return array(
'count' => count( $stale ),
'fetched_at' => $fetched_at,
);
return [ 'count' => count( $stale ), 'fetched_at' => $fetched_at ];
}
$normalized = $this->normalizer->normalize( $raw_models );
@@ -121,10 +113,7 @@ class PRAutoBlogger_OpenRouter_Model_Registry implements PRAutoBlogger_Model_Reg
self::LOG_CONTEXT
);
return array(
'count' => count( $normalized ),
'fetched_at' => $now,
);
return [ 'count' => count( $normalized ), 'fetched_at' => $now ];
}
/** {@inheritDoc} */
@@ -152,8 +141,8 @@ class PRAutoBlogger_OpenRouter_Model_Registry implements PRAutoBlogger_Model_Reg
/** @return array<int, array> */
private function load_from_option(): array {
$data = get_option( $this->option_name, array() );
return is_array( $data ) ? $data : array();
$data = get_option( $this->option_name, [] );
return is_array( $data ) ? $data : [];
}
private function save_payload( array $payload, int $now ): void {
@@ -172,7 +161,7 @@ class PRAutoBlogger_OpenRouter_Model_Registry implements PRAutoBlogger_Model_Reg
$base_delay = defined( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS' ) ? PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS : 2;
for ( $attempt = 1; $attempt <= $max_retries; $attempt++ ) {
$response = wp_remote_get( $this->endpoint, array( 'timeout' => self::HTTP_TIMEOUT ) );
$response = wp_remote_get( $this->endpoint, [ 'timeout' => self::HTTP_TIMEOUT ] );
if ( is_wp_error( $response ) ) {
PRAutoBlogger_Logger::instance()->warning(

View File

@@ -5,9 +5,9 @@ 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 3: a second implementation wraps Cloudflare Workers AI 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.
@@ -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

@@ -1,24 +0,0 @@
<?xml version="1.0"?>
<ruleset name="PRAutoBlogger">
<description>Custom PHPCS ruleset for PRAutoBlogger based on WordPress-Core</description>
<!-- Use WordPress-Core as the base standard -->
<rule ref="WordPress-Core">
<!-- Exclude the class file naming convention check -->
<!-- This is an architectural choice: we use short class names (class-logger.php)
instead of full names (class-prautoblogger-logger.php) for readability. -->
<exclude name="WordPress.Files.FileName.InvalidClassFileName" />
<!-- Exclude $wpdb->prepare() sniff for dynamic table names -->
<!-- PRAutoBlogger uses dynamically-constructed table names like {$prab_generation_logs}
that cannot be parameterized with $wpdb->prepare() since prepare() only supports
value placeholders, not table-name placeholders. These table names are validated
internally and do not come from user input. -->
<exclude name="WordPress.DB.PreparedSQL.InterpolatedNotPrepared" />
</rule>
<!-- File scope -->
<arg name="extensions" value="php" />
<arg name="ignore" value="vendor/*,tests/*,node_modules/*" />
<arg name="warning-severity" value="0" />
</ruleset>

View File

@@ -1,15 +1,14 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore Generic.PHP.RequireStrictTypes.MissingDeclaration -- strict_types must precede docblock
*
* PRAutoBlogger — Automated Content Research & Publishing
*
* @wordpress-plugin
* 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.1
* Version: 0.8.0
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: PeptideRepo
@@ -22,12 +21,12 @@
* @see ARCHITECTURE.md — Full data flow and file tree
* @see CONVENTIONS.md — Naming patterns and extension guides
*/
declare(strict_types=1);
// Abort if called directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/*
|--------------------------------------------------------------------------
| Plugin Constants
@@ -35,29 +34,32 @@ if ( ! defined( 'ABSPATH' ) ) {
| Defined here so every file in the plugin can reference paths, versions,
| and limits without magic strings.
*/
define( 'PRAUTOBLOGGER_VERSION', '0.10.1' );
define( 'PRAUTOBLOGGER_VERSION', '0.8.0' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'PRAUTOBLOGGER_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
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_CACHE_TTL_SECONDS', 3600 );
// Default models for OpenRouter (user can override in settings).
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' );
// Default provider is OpenRouter; Cloudflare is the fallback.
// Default model is Gemini 2.5 Flash Image ($0.005/image, good for comics).
// User can override via Settings → Images → Image Model.
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER', 'openrouter' );
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'google/gemini-2.5-flash-image' );
// 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.
@@ -66,24 +68,30 @@ 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.'
);
/*
|--------------------------------------------------------------------------
| Autoloader
|--------------------------------------------------------------------------
*/
require_once PRAUTOBLOGGER_PLUGIN_DIR . 'includes/class-autoloader.php';
PRAutoBlogger_Autoloader::register();
// The main orchestrator class is loaded explicitly because its CamelCase name
// (PRAutoBlogger) doesn't map cleanly to a filename via the autoloader's
// kebab-case convention (it would look for class-pr-auto-blogger.php).
require_once PRAUTOBLOGGER_PLUGIN_DIR . 'includes/class-prautoblogger.php';
/*
|--------------------------------------------------------------------------
| Activation & Deactivation
|--------------------------------------------------------------------------
*/
register_activation_hook( __FILE__, array( 'PRAutoBlogger_Activator', 'activate' ) );
register_deactivation_hook( __FILE__, array( 'PRAutoBlogger_Deactivator', 'deactivate' ) );
register_activation_hook( __FILE__, [ 'PRAutoBlogger_Activator', 'activate' ] );
register_deactivation_hook( __FILE__, [ 'PRAutoBlogger_Deactivator', 'deactivate' ] );
/*
|--------------------------------------------------------------------------
| Boot the Plugin
@@ -91,6 +99,7 @@ register_deactivation_hook( __FILE__, array( 'PRAutoBlogger_Deactivator', 'deact
| The main orchestrator registers all hooks. Nothing else happens here —
| keep the bootstrap file minimal.
*/
/**
* Returns the singleton instance of the main plugin class.
*
@@ -103,10 +112,8 @@ function prautoblogger(): PRAutoBlogger {
}
return $instance;
}
// Initialize on plugins_loaded so all dependencies are available.
add_action(
'plugins_loaded',
static function (): void {
prautoblogger()->run();
}
);
add_action( 'plugins_loaded', static function (): void {
prautoblogger()->run();
} );

View File

@@ -55,15 +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.' );

View File

@@ -38,17 +38,6 @@ abstract class BaseTestCase extends TestCase {
// home_url is used by OpenRouter provider in request headers.
Functions\when( 'home_url' )->justReturn( 'https://test.example.com' );
// post_type_exists is used by PeptideLinker to check if PR Core is active.
// Stub returns false to simulate when PR Core is not installed.
Functions\when( 'post_type_exists' )->justReturn( false );
// wp_salt is used by PRAutoBlogger_Encryption to derive encryption keys.
// Used when decrypting stored API keys. Stub returns a deterministic value.
Functions\when( 'wp_salt' )->justReturn( 'test-salt-deterministic-value' );
// wp_generate_uuid4 is used by CostTracker, ImageSideloader, and Runware provider.
// Stub returns a deterministic UUID so tests are reproducible.
Functions\when( 'wp_generate_uuid4' )->justReturn( '00000000-0000-0000-0000-000000000000' );
// current_time is called by Logger on every log write. Stub returns a
// deterministic timestamp so tests are reproducible.

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

@@ -45,17 +45,7 @@ class ImagePromptBuilderTest extends BaseTestCase {
}
/**
* 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 +55,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 +74,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 +94,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 +111,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,10 +129,10 @@ 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 ) );
// Prompt should be under 500 chars (concept + style suffix).
$this->assertLessThan( 500, strlen( $prompt ) );
}
/**

View File

@@ -228,12 +228,6 @@ class PublisherTest extends BaseTestCase {
public function test_publish_links_generation_logs_by_run_id(): void {
Functions\when( 'wp_insert_post' )->justReturn( 48 );
// Create an idea fixture without source_ids to avoid triggering SourceCollector
// queries that would overwrite captured_query in the mock.
$fixture = $this->get_article_idea_fixture();
$fixture['source_ids'] = [];
$idea = new \PRAutoBlogger_Article_Idea( $fixture );
$captured_query = null;
$wpdb = $this->create_mock_wpdb();
$wpdb->method( 'prepare' )->willReturnCallback( function ( $sql, ...$args ) use ( &$captured_query ) {
@@ -244,7 +238,7 @@ class PublisherTest extends BaseTestCase {
$GLOBALS['wpdb'] = $wpdb;
$publisher = new \PRAutoBlogger_Publisher();
$publisher->publish( '<p>Content</p>', $idea, $this->review, 'run_link_test' );
$publisher->publish( '<p>Content</p>', $this->idea, $this->review, 'run_link_test' );
// Should use run_id-based query, not timestamp-based.
$this->assertStringContainsString( 'run_id', $captured_query );

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