Compare commits
5 Commits
fix/v0.8.3
...
claude/v0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7026e2f0a | ||
|
|
52a8ffbd9e | ||
|
|
0505e50e85 | ||
|
|
fb1451c4dc | ||
|
|
a3ed663bae |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -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 \
|
||||
|
||||
3
.github/workflows/cto-review.yml
vendored
3
.github/workflows/cto-review.yml
vendored
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
198
CHANGELOG.md
198
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
) )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ) )
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ) {
|
||||
|
||||
@@ -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
|
||||
),
|
||||
] );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' ]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 ] );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' ),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' ),
|
||||
),
|
||||
);
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
@@ -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' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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').
|
||||
|
||||
@@ -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 ] );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'] ?? [] ),
|
||||
] );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' ),
|
||||
] );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ) ) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
] );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ) )
|
||||
)
|
||||
|
||||
@@ -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() );
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 ) );
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
78
includes/providers/class-cloudflare-image-pricing.php
Normal file
78
includes/providers/class-cloudflare-image-pricing.php
Normal 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 );
|
||||
}
|
||||
}
|
||||
296
includes/providers/class-cloudflare-image-provider.php
Normal file
296
includes/providers/class-cloudflare-image-provider.php
Normal 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 );
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
172
includes/providers/class-cloudflare-image-support.php
Normal file
172
includes/providers/class-cloudflare-image-support.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
122
includes/providers/class-cloudflare-image-validator.php
Normal file
122
includes/providers/class-cloudflare-image-validator.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.' );
|
||||
|
||||
@@ -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 ],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' )
|
||||
|
||||
@@ -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 )
|
||||
|
||||
@@ -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 ) ) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'] ?? '' ) ) {
|
||||
|
||||
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
24
phpcs.xml
24
phpcs.xml
@@ -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>
|
||||
@@ -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();
|
||||
} );
|
||||
|
||||
@@ -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.' );
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'] );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user